Skip to main content

dotzuki_renderer/layout_engine/elements/
text.rs

1//! Text element — renders text with template variable resolution, word
2//! wrapping, alignment, and colour mapping.
3//!
4//! ## Features
5//! - Template variable resolution via [`DataContext::resolve`]
6//! - Word wrapping when `TextParams::wrap` is `true`
7//! - Left / centre / right alignment within the element rect
8//! - Colour mapping from named ink-ramp strings and `#RRGGBB` hex literals
9
10use dotzuki_engine::render::painter::Painter;
11use dotzuki_engine::render::{Rgba, TilePos};
12
13use crate::layout_engine::types::{DataContext, LayoutElement, RenderContext, RenderError, TextAlign,
14    TextParams};
15
16// ── Public API ──────────────────────────────────────────────────────────────
17
18/// Render a text element into the framebuffer via `painter`.
19///
20/// # Arguments
21/// * `element` — The layout element containing position (`rect`) and
22///   text-specific parameters.
23/// * `params` — Deserialised [`TextParams`].
24/// * `ctx` — Data context for resolving template variables.
25/// * `_render_ctx` — Shared rendering state (fonts, tilesets, theme).
26/// * `painter` — Drawing backend.
27pub fn render_text(
28    element: &LayoutElement,
29    params: &TextParams,
30    ctx: &DataContext,
31    render_ctx: &RenderContext,
32    painter: &mut dyn Painter,
33) -> Result<(), RenderError> {
34    // ── Select language variant, then resolve template variables ──
35    // `params.value` may be a plain string or a `@t("en", "中文")` per-locale
36    // map; pick the active language (`DataContext::lang`) before substitution.
37    let localized = params.value.get(ctx.lang());
38    let resolved_text = ctx.resolve(localized);
39
40    // ── Rect dimensions (tile grid) ──
41    let rect = &element.rect;
42    let tile_width = rect.tw.unwrap_or(20);
43    let tile_height = rect.th.unwrap_or(18);
44    let base_tx = rect.tx.resolve(ctx);
45    let base_ty = rect.ty.resolve(ctx);
46    let align = params.align.as_ref().unwrap_or(&TextAlign::Left);
47    let wrap = params.wrap.as_deref() == Some("word");
48
49    // ── Proportional (pixel-precise) path — high-resolution / CJK screens ──
50    // Selected only when the theme opts in AND the painter supports it; pokered
51    // (Theme::default() = Tile, recording mocks) always falls through to the
52    // legacy tile path below, which is preserved byte-for-byte.
53    let theme = render_ctx.theme;
54    if theme.proportional(painter.supports_proportional()) {
55        let color = params
56            .color
57            .as_deref()
58            .map(parse_color)
59            .unwrap_or_else(|| theme.ink_color());
60        let base_px = base_tx * 8;
61        let base_py = base_ty * 8;
62        let width_px = tile_width * 8;
63        let height_px = tile_height * 8;
64        // Integer scale factor (1 = normal). Big title/heading text scales every
65        // glyph pixel into a block; row pitch and measurement scale with it.
66        let scale = params.scale.unwrap_or(1).max(1);
67        // Row pitch: full CJK glyph height + a little leading (+ optional spacing).
68        let line_h = (crate::embedded_font::GLYPH_SIZE + 3) * scale
69            + params.line_spacing.unwrap_or(0) as u32;
70        let lines = if wrap {
71            word_wrap_px(&resolved_text, width_px, &*painter)
72        } else {
73            resolved_text.split('\n').map(|l| l.to_string()).collect()
74        };
75        let mut y = base_py;
76        for line in &lines {
77            if y >= base_py + height_px {
78                break; // overflowed the rect
79            }
80            let w = painter.measure_text_px_scaled(line, scale);
81            let off = match align {
82                TextAlign::Left => 0,
83                TextAlign::Center => width_px.saturating_sub(w) / 2,
84                TextAlign::Right => width_px.saturating_sub(w),
85            };
86            painter.draw_text_px_scaled(base_px + off, y, line, scale, color);
87            y += line_h;
88        }
89        return Ok(());
90    }
91
92    // ── Legacy tile path (Game Boy 8×8 grid) — byte-identical to before ──
93    let color = params
94        .color
95        .as_deref()
96        .map(parse_color)
97        .unwrap_or(Rgba::INK_BLACK);
98    let max_chars = tile_width as usize;
99    let lines = if wrap {
100        word_wrap(&resolved_text, max_chars)
101    } else {
102        hard_break_lines(&resolved_text, max_chars)
103    };
104    let line_spacing = params.line_spacing.unwrap_or(0) as u32;
105    for (line_idx, line) in lines.iter().enumerate() {
106        let row = base_ty + line_idx as u32 * (1 + line_spacing);
107        if row >= base_ty + tile_height {
108            break; // would overflow the allocated rect
109        }
110
111        let text_width = line.chars().count().min(max_chars) as u32;
112        let offset_x = align_offset(text_width, tile_width, align);
113
114        for (char_idx, ch) in line.chars().enumerate() {
115            if char_idx >= max_chars {
116                break;
117            }
118            let col = base_tx + offset_x + char_idx as u32;
119            painter.draw_glyph(TilePos::new(col, row), ch, color);
120        }
121    }
122
123    Ok(())
124}
125
126// ── Helpers ────────────────────────────────────────────────────────────────
127
128/// Parse a colour string to an [`Rgba`].
129///
130/// Accepts the named ink-ramp shades — `"black"`, `"darkgray"` /
131/// `"dark_gray"`, `"lightgray"` / `"light_gray"`, `"white"` — and hex
132/// literals `#RGB`, `#RRGGBB`, or `#RRGGBBAA`. Unrecognised strings fall
133/// back to [`Rgba::INK_BLACK`].
134pub fn parse_color(s: &str) -> Rgba {
135    if let Some(hex) = s.strip_prefix('#') {
136        if let Some(c) = parse_hex_color(hex) {
137            return c;
138        }
139    }
140    match s.to_lowercase().as_str() {
141        "black" => Rgba::INK_BLACK,
142        "darkgray" | "dark_gray" => Rgba::INK_DARK_GRAY,
143        "lightgray" | "light_gray" => Rgba::INK_LIGHT_GRAY,
144        "white" => Rgba::INK_WHITE,
145        _ => Rgba::INK_BLACK,
146    }
147}
148
149fn parse_hex_color(hex: &str) -> Option<Rgba> {
150    let nibble = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
151    let byte = |hi: u8, lo: u8| Some(nibble(hi)? * 16 + nibble(lo)?);
152    let b = hex.as_bytes();
153    match b.len() {
154        3 => Some(Rgba::rgb(
155            byte(b[0], b[0])?,
156            byte(b[1], b[1])?,
157            byte(b[2], b[2])?,
158        )),
159        6 => Some(Rgba::rgb(byte(b[0], b[1])?, byte(b[2], b[3])?, byte(b[4], b[5])?)),
160        8 => Some(Rgba::new(
161            byte(b[0], b[1])?,
162            byte(b[2], b[3])?,
163            byte(b[4], b[5])?,
164            byte(b[6], b[7])?,
165        )),
166        _ => None,
167    }
168}
169
170/// Split text on explicit newlines, then truncate each line to `max_chars`.
171fn hard_break_lines(text: &str, max_chars: usize) -> Vec<String> {
172    text.lines()
173        .map(|line| {
174            if line.chars().count() > max_chars {
175                line.chars().take(max_chars).collect()
176            } else {
177                line.to_string()
178            }
179        })
180        .collect()
181}
182
183/// Word-wrap `text` to fit within `max_chars` per line, breaking at
184/// space boundaries when possible.  Words longer than a line are
185/// hard-broken.
186pub fn word_wrap(text: &str, max_chars: usize) -> Vec<String> {
187    let mut lines: Vec<String> = Vec::new();
188    let mut current = String::new();
189
190    for word in text.split_whitespace() {
191        if current.is_empty() {
192            if word.chars().count() > max_chars {
193                // Long word — force-break
194                for chunk in chunk_str(word, max_chars) {
195                    lines.push(chunk.to_string());
196                }
197            } else {
198                current = word.to_string();
199            }
200        } else if current.chars().count() + 1 + word.chars().count() <= max_chars {
201            current.push(' ');
202            current.push_str(word);
203        } else {
204            lines.push(std::mem::take(&mut current));
205            if word.chars().count() > max_chars {
206                for chunk in chunk_str(word, max_chars) {
207                    lines.push(chunk.to_string());
208                }
209            } else {
210                current = word.to_string();
211            }
212        }
213    }
214
215    if !current.is_empty() {
216        lines.push(current);
217    }
218
219    if lines.is_empty() {
220        lines.push(String::new());
221    }
222
223    lines
224}
225
226/// Word-wrap `text` to a pixel `max_px` width using the painter's proportional
227/// font metrics ([`Painter::measure_text_px`]). Breaks at spaces where possible;
228/// a run wider than a line (e.g. CJK with no spaces) is split per-character by
229/// measured width. Explicit `\n` start new lines. Used by the proportional path.
230pub fn word_wrap_px(text: &str, max_px: u32, painter: &dyn Painter) -> Vec<String> {
231    let max_px = max_px.max(1);
232    let space_px = painter.measure_text_px(" ");
233    let mut lines: Vec<String> = Vec::new();
234
235    for raw in text.split('\n') {
236        let mut current = String::new();
237        let mut cur_px = 0u32;
238        for word in raw.split(' ') {
239            if word.is_empty() {
240                continue;
241            }
242            let word_px = painter.measure_text_px(word);
243            if word_px <= max_px {
244                let sep = if current.is_empty() { 0 } else { space_px };
245                if cur_px + sep + word_px <= max_px {
246                    if !current.is_empty() {
247                        current.push(' ');
248                        cur_px += sep;
249                    }
250                    current.push_str(word);
251                    cur_px += word_px;
252                } else {
253                    lines.push(std::mem::take(&mut current));
254                    current.push_str(word);
255                    cur_px = word_px;
256                }
257            } else {
258                // Word wider than a whole line — break it character by character.
259                if !current.is_empty() {
260                    lines.push(std::mem::take(&mut current));
261                    cur_px = 0;
262                }
263                for ch in word.chars() {
264                    let ch_px = painter.measure_text_px(ch.encode_utf8(&mut [0u8; 4]));
265                    if !current.is_empty() && cur_px + ch_px > max_px {
266                        lines.push(std::mem::take(&mut current));
267                        cur_px = 0;
268                    }
269                    current.push(ch);
270                    cur_px += ch_px;
271                }
272            }
273        }
274        lines.push(current); // preserve blank lines from explicit \n
275    }
276
277    if lines.is_empty() {
278        lines.push(String::new());
279    }
280    lines
281}
282
283/// Split a string into chunks of at most `max_chars` characters.
284fn chunk_str(s: &str, max_chars: usize) -> Vec<&str> {
285    let mut chunks = Vec::new();
286    let mut remaining = s;
287    while !remaining.is_empty() {
288        let end = remaining
289            .char_indices()
290            .take(max_chars)
291            .last()
292            .map(|(i, c)| i + c.len_utf8())
293            .unwrap_or(remaining.len());
294        chunks.push(&remaining[..end]);
295        remaining = &remaining[end..];
296    }
297    chunks
298}
299
300/// Compute horizontal tile offset for a given alignment.
301///
302/// Returns the number of tile columns to shift right so that a
303/// `text_width`-tile-wide string sits at the left, centre, or right
304/// of an `available_width`-tile-wide area.
305pub fn align_offset(text_width: u32, available_width: u32, align: &TextAlign) -> u32 {
306    match align {
307        TextAlign::Left => 0,
308        TextAlign::Center => {
309            if text_width >= available_width {
310                0
311            } else {
312                (available_width - text_width) / 2
313            }
314        }
315        TextAlign::Right => available_width.saturating_sub(text_width),
316    }
317}
318
319// ── Tests ──────────────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::layout_engine::types::{Coord, ElementParams, ElementRect};
325    use dotzuki_engine::render::Rgba as EngineRgba;
326
327    // ── Recording painter ────────────────────────────────────────────
328
329    #[derive(Debug, Default)]
330    struct RecordingPainter {
331        glyphs: Vec<(TilePos, char, EngineRgba)>,
332        texts: Vec<(TilePos, String, EngineRgba)>,
333    }
334
335    impl RecordingPainter {
336        fn new() -> Self {
337            Self::default()
338        }
339
340        fn glyph_at(&self, tx: u32, ty: u32) -> Option<char> {
341            self.glyphs
342                .iter()
343                .find(|(pos, _, _)| pos.tx == tx && pos.ty == ty)
344                .map(|(_, ch, _)| *ch)
345        }
346    }
347
348    impl Painter for RecordingPainter {
349        fn clear(&mut self, _color: EngineRgba) {}
350
351        fn draw_text_box(
352            &mut self,
353            _rect: dotzuki_engine::render::TileRect,
354            _color: EngineRgba,
355        ) {
356        }
357
358        fn draw_text(&mut self, pos: TilePos, text: &str, color: EngineRgba) {
359            self.texts.push((pos, text.to_string(), color));
360        }
361
362        fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: EngineRgba) {
363            self.glyphs.push((pos, glyph, color));
364        }
365
366        fn draw_pixel_rect(
367            &mut self,
368            _px: u32,
369            _py: u32,
370            _pw: u32,
371            _ph: u32,
372            _color: EngineRgba,
373        ) {
374        }
375
376        fn draw_gb_tile(
377            &mut self,
378            _pos: TilePos,
379            _tile_id: u8,
380            _fallback: &str,
381            _color: EngineRgba,
382        ) {
383        }
384    }
385
386    // ── Helpers ──────────────────────────────────────────────────────
387
388    fn make_element(value: &str, tx: u32, ty: u32, tw: u32, th: u32) -> LayoutElement {
389        LayoutElement {
390            id: String::new(),
391            element_type: "text".to_string(),
392            rect: ElementRect {
393                tx: Coord::Literal(tx),
394                ty: Coord::Literal(ty),
395                tw: Some(tw),
396                th: Some(th),
397            },
398            visible: crate::layout_engine::types::Visibility::Static(true),
399            z_index: 0,
400            params: ElementParams::Text(TextParams {
401                value: value.into(),
402                format: None,
403                color: None,
404                align: None,
405                font: None,
406                wrap: None,
407                line_spacing: None,
408                scale: None,
409            }),
410        }
411    }
412
413    fn make_theme() -> crate::layout_engine::types::Theme {
414        Default::default()
415    }
416
417    fn render_ctx<'a>(
418        theme: &'a crate::layout_engine::types::Theme,
419        fonts: &'a std::collections::HashMap<String, ()>,
420        tilesets: &'a std::collections::HashMap<String, ()>,
421    ) -> RenderContext<'a> {
422        RenderContext {
423            screen: "test",
424            theme,
425            fonts,
426            tilesets,
427            images: crate::layout_engine::types::empty_image_registry(),
428        }
429    }
430
431    // ── Tests: parse_color ───────────────────────────────────────────
432
433    #[test]
434    fn parse_color_black() {
435        assert_eq!(parse_color("black"), Rgba::INK_BLACK);
436    }
437
438    #[test]
439    fn parse_color_darkgray_variants() {
440        assert_eq!(parse_color("darkgray"), Rgba::INK_DARK_GRAY);
441        assert_eq!(parse_color("dark_gray"), Rgba::INK_DARK_GRAY);
442    }
443
444    #[test]
445    fn parse_color_lightgray_variants() {
446        assert_eq!(parse_color("lightgray"), Rgba::INK_LIGHT_GRAY);
447        assert_eq!(parse_color("light_gray"), Rgba::INK_LIGHT_GRAY);
448    }
449
450    #[test]
451    fn parse_color_white() {
452        assert_eq!(parse_color("white"), Rgba::INK_WHITE);
453    }
454
455    #[test]
456    fn parse_color_case_insensitive() {
457        assert_eq!(parse_color("BLACK"), Rgba::INK_BLACK);
458        assert_eq!(parse_color("White"), Rgba::INK_WHITE);
459    }
460
461    #[test]
462    fn parse_color_unknown_returns_black() {
463        assert_eq!(parse_color("red"), Rgba::INK_BLACK);
464        assert_eq!(parse_color(""), Rgba::INK_BLACK);
465    }
466
467    // ── Tests: word_wrap ─────────────────────────────────────────────
468
469    #[test]
470    fn word_wrap_short_text() {
471        assert_eq!(word_wrap("hello", 10), vec!["hello"]);
472    }
473
474    #[test]
475    fn word_wrap_splits_at_space() {
476        let lines = word_wrap("hello world test", 10);
477        assert_eq!(lines, vec!["hello", "world test"]);
478    }
479
480    #[test]
481    fn word_wrap_exact_fit() {
482        assert_eq!(word_wrap("12345 12345", 5), vec!["12345", "12345"]);
483    }
484
485    #[test]
486    fn word_wrap_long_word_breaks() {
487        let lines = word_wrap("supercalifragilistic", 5);
488        for line in &lines {
489            assert!(line.chars().count() <= 5, "{:?} too long", line);
490        }
491        assert!(lines.len() > 1);
492    }
493
494    #[test]
495    fn word_wrap_empty_returns_empty_line() {
496        assert_eq!(word_wrap("", 10), vec![""]);
497    }
498
499    #[test]
500    fn word_wrap_preserves_spaces_in_result() {
501        let lines = word_wrap("a b c", 3);
502        // "a b" fits, "c" on next line
503        assert_eq!(lines, vec!["a b", "c"]);
504    }
505
506    // ── Tests: align_offset ──────────────────────────────────────────
507
508    #[test]
509    fn align_left_is_zero() {
510        assert_eq!(align_offset(5, 20, &TextAlign::Left), 0);
511    }
512
513    #[test]
514    fn align_center() {
515        assert_eq!(align_offset(5, 20, &TextAlign::Center), 7); // (20-5)/2
516        assert_eq!(align_offset(4, 20, &TextAlign::Center), 8); // (20-4)/2
517    }
518
519    #[test]
520    fn align_center_clamped() {
521        assert_eq!(align_offset(25, 20, &TextAlign::Center), 0);
522    }
523
524    #[test]
525    fn align_right() {
526        assert_eq!(align_offset(5, 20, &TextAlign::Right), 15); // 20-5
527        assert_eq!(align_offset(25, 20, &TextAlign::Right), 0); // saturating
528    }
529
530    // ── Tests: render_text ───────────────────────────────────────────
531
532    #[test]
533    fn render_simple_text() {
534        let elem = make_element("Hello", 2, 3, 20, 18);
535        let params = match &elem.params {
536            ElementParams::Text(p) => p,
537            _ => unreachable!(),
538        };
539        let ctx = DataContext::new();
540        let theme = Default::default();
541        let fonts = std::collections::HashMap::new();
542        let tilesets = std::collections::HashMap::new();
543        let rc = render_ctx(&theme, &fonts, &tilesets);
544        let mut p = RecordingPainter::new();
545
546        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
547
548        assert_eq!(p.glyphs.len(), 5);
549        assert_eq!(p.glyph_at(2, 3), Some('H'));
550        assert_eq!(p.glyph_at(3, 3), Some('e'));
551        assert_eq!(p.glyph_at(6, 3), Some('o'));
552    }
553
554    #[test]
555    fn render_template_resolution() {
556        let elem = make_element("{name} Lv{level}", 0, 0, 20, 18);
557        let params = match &elem.params {
558            ElementParams::Text(p) => p,
559            _ => unreachable!(),
560        };
561        let mut ctx = DataContext::new();
562        ctx.set("name", "SPARKIT");
563        ctx.set("level", 25i64);
564        let theme = make_theme();
565        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
566        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
567        let rc = render_ctx(&theme, &fonts, &tilesets);
568        let mut p = RecordingPainter::new();
569
570        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
571
572        // Should produce "SPARKIT Lv25"
573        let rendered: String = p.glyphs.iter().map(|(_, ch, _)| *ch).collect();
574        assert_eq!(rendered, "SPARKIT Lv25");
575    }
576
577    #[test]
578    fn localized_value_get_selects_and_falls_back() {
579        use crate::layout_engine::types::LocalizedValue;
580        let mut m = std::collections::BTreeMap::new();
581        m.insert("en".to_string(), "YES".to_string());
582        m.insert("zh".to_string(), "是".to_string());
583        let lv = LocalizedValue::Localized(m);
584        assert_eq!(lv.get("zh"), "是");
585        assert_eq!(lv.get("en"), "YES");
586        // Unknown locale falls back to `en`.
587        assert_eq!(lv.get("ja"), "YES");
588        // Plain returns itself for any locale.
589        assert_eq!(LocalizedValue::Plain("HI".into()).get("zh"), "HI");
590    }
591
592    #[test]
593    fn render_localized_text_picks_active_language() {
594        use crate::layout_engine::types::LocalizedValue;
595        let mut elem = make_element("placeholder", 0, 0, 20, 18);
596        if let ElementParams::Text(ref mut tp) = elem.params {
597            let mut m = std::collections::BTreeMap::new();
598            m.insert("en".to_string(), "YES".to_string());
599            m.insert("zh".to_string(), "是".to_string());
600            tp.value = LocalizedValue::Localized(m);
601        }
602        let params = match &elem.params {
603            ElementParams::Text(p) => p,
604            _ => unreachable!(),
605        };
606        let theme = make_theme();
607        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
608        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
609        let rc = render_ctx(&theme, &fonts, &tilesets);
610
611        // Chinese: `__lang = "zh"` selects the zh variant.
612        let mut ctx = DataContext::new();
613        ctx.set("__lang", "zh");
614        let mut p = RecordingPainter::new();
615        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
616        let zh: String = p.glyphs.iter().map(|(_, ch, _)| *ch).collect();
617        assert_eq!(zh, "是");
618
619        // Default (no `__lang`) falls back to English.
620        let ctx_default = DataContext::new();
621        let mut p2 = RecordingPainter::new();
622        render_text(&elem, params, &ctx_default, &rc, &mut p2).unwrap();
623        let en: String = p2.glyphs.iter().map(|(_, ch, _)| *ch).collect();
624        assert_eq!(en, "YES");
625    }
626
627    #[test]
628    fn render_center_aligned() {
629        let mut elem = make_element("AB", 0, 0, 10, 18);
630        if let ElementParams::Text(ref mut tp) = elem.params {
631            tp.align = Some(TextAlign::Center);
632        }
633        let params = match &elem.params {
634            ElementParams::Text(p) => p,
635            _ => unreachable!(),
636        };
637        let ctx = DataContext::new();
638        let theme = make_theme();
639        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
640        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
641        let rc = render_ctx(&theme, &fonts, &tilesets);
642        let mut p = RecordingPainter::new();
643
644        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
645
646        // width=10, text="AB"=2 tiles → offset (10-2)/2=4
647        assert_eq!(p.glyph_at(4, 0), Some('A'));
648        assert_eq!(p.glyph_at(5, 0), Some('B'));
649    }
650
651    #[test]
652    fn render_right_aligned() {
653        let mut elem = make_element("X", 0, 0, 5, 18);
654        if let ElementParams::Text(ref mut tp) = elem.params {
655            tp.align = Some(TextAlign::Right);
656        }
657        let params = match &elem.params {
658            ElementParams::Text(p) => p,
659            _ => unreachable!(),
660        };
661        let ctx = DataContext::new();
662        let theme = make_theme();
663        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
664        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
665        let rc = render_ctx(&theme, &fonts, &tilesets);
666        let mut p = RecordingPainter::new();
667
668        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
669
670        // width=5, text="X"=1 → offset 5-1=4
671        assert_eq!(p.glyph_at(4, 0), Some('X'));
672    }
673
674    #[test]
675    fn render_word_wrap() {
676        let mut elem = make_element("hello world foo bar baz", 0, 0, 6, 18);
677        if let ElementParams::Text(ref mut tp) = elem.params {
678            tp.wrap = Some("word".to_string());
679        }
680        let params = match &elem.params {
681            ElementParams::Text(p) => p,
682            _ => unreachable!(),
683        };
684        let ctx = DataContext::new();
685        let theme = make_theme();
686        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
687        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
688        let rc = render_ctx(&theme, &fonts, &tilesets);
689        let mut p = RecordingPainter::new();
690
691        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
692
693        // Line 0: "hello " → ty=0; Line 1: "world " → ty=1; etc.
694        let has_row_0 = p.glyphs.iter().any(|(pos, _, _)| pos.ty == 0);
695        let has_row_1 = p.glyphs.iter().any(|(pos, _, _)| pos.ty == 1);
696        let has_row_2 = p.glyphs.iter().any(|(pos, _, _)| pos.ty == 2);
697        assert!(has_row_0);
698        assert!(has_row_1);
699        assert!(has_row_2);
700    }
701
702    #[test]
703    fn render_color_from_params() {
704        let mut elem = make_element("Hi", 0, 0, 20, 18);
705        if let ElementParams::Text(ref mut tp) = elem.params {
706            tp.color = Some("white".to_string());
707        }
708        let params = match &elem.params {
709            ElementParams::Text(p) => p,
710            _ => unreachable!(),
711        };
712        let ctx = DataContext::new();
713        let theme = make_theme();
714        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
715        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
716        let rc = render_ctx(&theme, &fonts, &tilesets);
717        let mut p = RecordingPainter::new();
718
719        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
720
721        assert!(!p.glyphs.is_empty());
722        assert_eq!(p.glyphs[0].2, Rgba::INK_WHITE);
723    }
724
725    #[test]
726    fn render_clips_to_rect_height() {
727        let elem = make_element("A\nB\nC\nD\nE", 0, 0, 20, 2);
728        let params = match &elem.params {
729            ElementParams::Text(p) => p,
730            _ => unreachable!(),
731        };
732        let ctx = DataContext::new();
733        let theme = make_theme();
734        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
735        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
736        let rc = render_ctx(&theme, &fonts, &tilesets);
737        let mut p = RecordingPainter::new();
738
739        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
740
741        // Only rows 0 and 1 should be drawn (th=2)
742        let max_ty = p.glyphs.iter().map(|(pos, _, _)| pos.ty).max().unwrap_or(0);
743        assert!(max_ty < 2, "expected rows < 2, got max_ty={}", max_ty);
744    }
745
746    #[test]
747    fn render_font_config() {
748        // font field is accepted but currently unused (font selection is
749        // handled by the render context at a higher level)
750        let mut elem = make_element("OK", 0, 0, 20, 18);
751        if let ElementParams::Text(ref mut tp) = elem.params {
752            tp.font = Some("battle".to_string());
753        }
754        let params = match &elem.params {
755            ElementParams::Text(p) => p,
756            _ => unreachable!(),
757        };
758        let ctx = DataContext::new();
759        let theme = make_theme();
760        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
761        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
762        let rc = render_ctx(&theme, &fonts, &tilesets);
763        let mut p = RecordingPainter::new();
764
765        let result = render_text(&elem, params, &ctx, &rc, &mut p);
766        assert!(result.is_ok());
767        let rendered: String = p.glyphs.iter().map(|(_, ch, _)| *ch).collect();
768        assert_eq!(rendered, "OK");
769    }
770
771    #[test]
772    fn render_line_spacing() {
773        let mut elem = make_element("A\nB", 0, 0, 20, 18);
774        if let ElementParams::Text(ref mut tp) = elem.params {
775            tp.line_spacing = Some(2); // 2 extra rows between lines
776        }
777        let params = match &elem.params {
778            ElementParams::Text(p) => p,
779            _ => unreachable!(),
780        };
781        let ctx = DataContext::new();
782        let theme = make_theme();
783        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
784        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
785        let rc = render_ctx(&theme, &fonts, &tilesets);
786        let mut p = RecordingPainter::new();
787
788        render_text(&elem, params, &ctx, &rc, &mut p).unwrap();
789
790        // "A" at ty=0, "B" at ty = 0 + 1*(1+2) = 3
791        assert_eq!(p.glyph_at(0, 0), Some('A'));
792        assert_eq!(p.glyph_at(0, 3), Some('B'));
793    }
794
795    // ── Tests: chunk_str ─────────────────────────────────────────────
796
797    #[test]
798    fn chunk_str_simple() {
799        assert_eq!(chunk_str("abc", 2), vec!["ab", "c"]);
800    }
801
802    #[test]
803    fn chunk_str_exact_multiple() {
804        assert_eq!(chunk_str("abcd", 2), vec!["ab", "cd"]);
805    }
806
807    #[test]
808    fn chunk_str_empty() {
809        let v: Vec<&str> = Vec::new();
810        assert_eq!(chunk_str("", 5), v);
811    }
812}