Skip to main content

dotzuki_renderer/layout_engine/
renderer.rs

1use crate::layout_engine::elements::group::{Group, GroupLayout};
2use crate::layout_engine::registry::ElementRegistry;
3use crate::layout_engine::types::{
4    Coord, DataContext, ElementParams, LayoutElement, RenderContext, RenderError, ScreenLayout,
5};
6use dotzuki_engine::render::painter::Painter;
7use dotzuki_engine::render::{Rgba, TileRect};
8
9/// Render a screen, first clearing to the theme background. Use for
10/// full-screen menus (main, options, stats, …).
11pub fn render_layout(
12    layout: &ScreenLayout,
13    ctx: &DataContext,
14    render_ctx: &RenderContext,
15    registry: &ElementRegistry,
16    painter: &mut dyn Painter,
17) -> Result<(), RenderError> {
18    let bg = parse_bg_color(&layout.theme.bg_color);
19    painter.clear(bg);
20    render_elements(layout, ctx, render_ctx, registry, painter)
21}
22
23/// Render a screen WITHOUT clearing — for overlays drawn on top of an existing
24/// scene (e.g. the battle action menu over the battle sprites).
25pub fn render_layout_no_clear(
26    layout: &ScreenLayout,
27    ctx: &DataContext,
28    render_ctx: &RenderContext,
29    registry: &ElementRegistry,
30    painter: &mut dyn Painter,
31) -> Result<(), RenderError> {
32    render_elements(layout, ctx, render_ctx, registry, painter)
33}
34
35fn render_elements(
36    layout: &ScreenLayout,
37    ctx: &DataContext,
38    render_ctx: &RenderContext,
39    registry: &ElementRegistry,
40    painter: &mut dyn Painter,
41) -> Result<(), RenderError> {
42    let mut elements: Vec<&LayoutElement> = layout.elements.iter().collect();
43    elements.sort_by_key(|e| e.z_index);
44
45    for element in &elements {
46        if !element.visible.eval(ctx) {
47            continue;
48        }
49        dispatch_element(element, ctx, render_ctx, registry, painter)?;
50    }
51
52    Ok(())
53}
54
55// ── Dispatch ───────────────────────────────────────────────────────────────
56
57fn dispatch_element(
58    element: &LayoutElement,
59    ctx: &DataContext,
60    render_ctx: &RenderContext,
61    registry: &ElementRegistry,
62    painter: &mut dyn Painter,
63) -> Result<(), RenderError> {
64    match element.element_type.as_str() {
65        "border" => render_border_element(element, ctx, render_ctx, registry, painter),
66        "text" => render_text_element(element, ctx, render_ctx, painter),
67        "tile" => render_tile_element(element, ctx, render_ctx, painter),
68        "divider" => render_divider_element(element, ctx, render_ctx, painter),
69        "image" => render_image_element(element, ctx, render_ctx, painter),
70        "list" => render_list_element(element, ctx, render_ctx, painter),
71        "flex_list" => render_flex_list_element(element, ctx, render_ctx, painter),
72        "cursor" => render_cursor_element(element, ctx, render_ctx, painter),
73        "bracket" => render_bracket_element(element, ctx, painter),
74        "pixel_rect" => render_pixel_rect_element(element, painter),
75        "group" => render_group_element(element, ctx, render_ctx, registry, painter),
76        t if t.starts_with("custom:") => {
77            if let Some(custom) = registry.get(t) {
78                custom.render(element, ctx, render_ctx, painter)
79            } else {
80                log::warn!("Custom element type '{}' not registered — skipping", t);
81                Ok(())
82            }
83        }
84        _ => {
85            log::warn!("Unknown element type '{}' — skipping", element.element_type);
86            Ok(())
87        }
88    }
89}
90
91// ── Element renderers ──────────────────────────────────────────────────────
92
93fn render_border_element(
94    element: &LayoutElement,
95    ctx: &DataContext,
96    render_ctx: &RenderContext,
97    registry: &ElementRegistry,
98    painter: &mut dyn Painter,
99) -> Result<(), RenderError> {
100    let ElementParams::Border(ref params) = element.params else {
101        return Ok(());
102    };
103
104    let rect = TileRect::new(
105        element.rect.tx.resolve(ctx),
106        element.rect.ty.resolve(ctx),
107        element.rect.tw.unwrap_or(1),
108        element.rect.th.unwrap_or(1),
109    );
110
111    // Draw a Game Boy-style rounded text box (corners, edges, white fill).
112    // This uses the painter's native box rendering, which draws the proper
113    // 8-piece border instead of the placeholder per-tile fallback.
114    painter.draw_text_box(rect, Rgba::INK_BLACK);
115    // tileset/style unused in MVP — always default box tiles.
116
117    // Render any nested children (e.g. a dialog box's text). Unlike `group`,
118    // a panel does not reposition its children: their rects are already
119    // absolute screen coordinates, so dispatch them as-is.
120    for child in &params.children {
121        if !child.visible.eval(ctx) {
122            continue;
123        }
124        dispatch_element(child, ctx, render_ctx, registry, painter)?;
125    }
126    Ok(())
127}
128
129fn render_text_element(
130    element: &LayoutElement,
131    ctx: &DataContext,
132    render_ctx: &RenderContext,
133    painter: &mut dyn Painter,
134) -> Result<(), RenderError> {
135    let ElementParams::Text(ref params) = element.params else {
136        return Ok(());
137    };
138    crate::layout_engine::elements::text::render_text(
139        element, params, ctx, render_ctx, painter,
140    )
141}
142
143fn render_tile_element(
144    element: &LayoutElement,
145    ctx: &DataContext,
146    render_ctx: &RenderContext,
147    painter: &mut dyn Painter,
148) -> Result<(), RenderError> {
149    let ElementParams::Tile(ref params) = element.params else {
150        return Ok(());
151    };
152    crate::layout_engine::elements::tile::render_tile(
153        element, params, ctx, render_ctx, painter,
154    )
155}
156
157fn render_divider_element(
158    element: &LayoutElement,
159    ctx: &DataContext,
160    render_ctx: &RenderContext,
161    painter: &mut dyn Painter,
162) -> Result<(), RenderError> {
163    let ElementParams::Divider(ref params) = element.params else {
164        return Ok(());
165    };
166    crate::layout_engine::elements::divider::render_divider(
167        element, params, ctx, render_ctx, painter,
168    )
169}
170
171fn render_image_element(
172    element: &LayoutElement,
173    ctx: &DataContext,
174    render_ctx: &RenderContext,
175    painter: &mut dyn Painter,
176) -> Result<(), RenderError> {
177    let ElementParams::Image(ref params) = element.params else {
178        return Ok(());
179    };
180    crate::layout_engine::elements::image::render_image(
181        element, params, ctx, render_ctx, painter,
182    )
183}
184
185fn render_list_element(
186    element: &LayoutElement,
187    ctx: &DataContext,
188    render_ctx: &RenderContext,
189    painter: &mut dyn Painter,
190) -> Result<(), RenderError> {
191    let ElementParams::List(ref params) = element.params else {
192        return Ok(());
193    };
194    crate::layout_engine::elements::list::render_list(
195        params,
196        element.rect.tx.resolve(ctx),
197        element.rect.ty.resolve(ctx),
198        ctx,
199        render_ctx.theme,
200        painter,
201    );
202    Ok(())
203}
204
205fn render_flex_list_element(
206    element: &LayoutElement,
207    ctx: &DataContext,
208    render_ctx: &RenderContext,
209    painter: &mut dyn Painter,
210) -> Result<(), RenderError> {
211    let ElementParams::FlexList(ref params) = element.params else {
212        return Ok(());
213    };
214    crate::layout_engine::elements::flex_list::render_flex_list(
215        params,
216        element.rect.tx.resolve(ctx),
217        element.rect.ty.resolve(ctx),
218        ctx,
219        render_ctx.theme,
220        painter,
221    );
222    Ok(())
223}
224
225fn render_cursor_element(
226    element: &LayoutElement,
227    ctx: &DataContext,
228    render_ctx: &RenderContext,
229    painter: &mut dyn Painter,
230) -> Result<(), RenderError> {
231    let ElementParams::Cursor(ref params) = element.params else {
232        return Ok(());
233    };
234    crate::layout_engine::elements::cursor::render_cursor(
235        element,
236        params,
237        ctx,
238        render_ctx.theme,
239        painter,
240    );
241    Ok(())
242}
243
244fn render_bracket_element(
245    element: &LayoutElement,
246    ctx: &DataContext,
247    painter: &mut dyn Painter,
248) -> Result<(), RenderError> {
249    let ElementParams::Bracket(ref params) = element.params else {
250        return Ok(());
251    };
252    crate::layout_engine::elements::primitives::render_bracket(element, params, ctx, painter);
253    Ok(())
254}
255
256fn render_pixel_rect_element(
257    element: &LayoutElement,
258    painter: &mut dyn Painter,
259) -> Result<(), RenderError> {
260    let ElementParams::PixelRect(ref params) = element.params else {
261        return Ok(());
262    };
263    crate::layout_engine::elements::primitives::render_pixel_rect(params, painter);
264    Ok(())
265}
266
267fn render_group_element(
268    element: &LayoutElement,
269    ctx: &DataContext,
270    render_ctx: &RenderContext,
271    registry: &ElementRegistry,
272    painter: &mut dyn Painter,
273) -> Result<(), RenderError> {
274    let ElementParams::Group(ref params) = element.params else {
275        return Ok(());
276    };
277
278    let rect = TileRect::new(
279        element.rect.tx.resolve(ctx),
280        element.rect.ty.resolve(ctx),
281        element.rect.tw.unwrap_or(20),
282        element.rect.th.unwrap_or(18),
283    );
284
285    let layout = GroupLayout::from_config(&params.layout);
286    let group = Group::new(rect)
287        .with_layout(layout)
288        .with_clip(params.clip);
289
290    // Resolve children to absolute positions
291    let resolved = group.resolve_children(&params.children, ctx);
292
293    // Sort children by z_index
294    let mut indices: Vec<usize> = (0..resolved.len()).collect();
295    indices.sort_by_key(|&i| resolved[i].z_index);
296
297    // Render children
298    for &child_idx in &indices {
299        let child_rect = &resolved[child_idx];
300        if !child_rect.visible {
301            continue;
302        }
303        let child = &params.children[child_idx];
304
305        // Create a temporary element with the resolved absolute rect
306        let mut resolved_child = child.clone();
307        resolved_child.rect.tx = Coord::Literal(child_rect.rect.tx);
308        resolved_child.rect.ty = Coord::Literal(child_rect.rect.ty);
309
310        dispatch_element(&resolved_child, ctx, render_ctx, registry, painter)?;
311    }
312
313    Ok(())
314}
315
316// ── Helpers ────────────────────────────────────────────────────────────────
317
318fn parse_bg_color(hex: &str) -> Rgba {
319    match hex.to_lowercase().as_str() {
320        // Preserve the exact Game Boy ink-ramp mappings (pokered relies on these:
321        // e.g. "#FFFFFF" → the off-white INK_WHITE, not pure 0xFFFFFF).
322        "#000000" | "black" => Rgba::INK_BLACK,
323        "#606060" | "#808080" | "darkgray" | "dark_gray" => Rgba::INK_DARK_GRAY,
324        "#a0a0a0" | "#c0c0c0" | "lightgray" | "light_gray" => Rgba::INK_LIGHT_GRAY,
325        "#ffffff" | "#e0e0e0" | "white" => Rgba::INK_WHITE,
326        // Any other hex value (e.g. a full-colour theme like wuxia's parchment
327        // "#18140F") is parsed as a literal colour.
328        other if other.starts_with('#') => {
329            crate::layout_engine::elements::text::parse_color(other)
330        }
331        // Unrecognised non-hex names keep the legacy white fallback.
332        other => {
333            log::warn!("Unknown bg_color '{}' — falling back to White", other);
334            Rgba::INK_WHITE
335        }
336    }
337}
338
339// ── Tests ──────────────────────────────────────────────────────────────────
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::layout_engine::types::Theme;
345    use dotzuki_engine::render::Rgba as EngineRgba;
346    use dotzuki_engine::render::TilePos;
347    use std::collections::HashMap;
348
349    // ── RecordingPainter ──────────────────────────────────────────────
350
351    #[derive(Debug, Clone)]
352    enum DrawOp {
353        Clear(EngineRgba),
354        TextBox(TileRect, EngineRgba),
355        Text(TilePos, String, EngineRgba),
356        Glyph(TilePos, char, EngineRgba),
357        PixelRect(u32, u32, u32, u32, EngineRgba),
358        GbTile(TilePos, u8, String, EngineRgba),
359    }
360
361    #[derive(Debug, Default)]
362    struct RecordingPainter {
363        ops: Vec<DrawOp>,
364    }
365
366    impl RecordingPainter {
367        fn new() -> Self {
368            Self::default()
369        }
370
371        fn has_op<F: Fn(&DrawOp) -> bool>(&self, pred: F) -> bool {
372            self.ops.iter().any(pred)
373        }
374
375        fn gb_tiles(&self) -> Vec<(TilePos, u8)> {
376            self.ops
377                .iter()
378                .filter_map(|op| match op {
379                    DrawOp::GbTile(pos, id, _, _) => Some((*pos, *id)),
380                    _ => None,
381                })
382                .collect()
383        }
384
385        fn glyphs(&self) -> Vec<(TilePos, char)> {
386            self.ops
387                .iter()
388                .filter_map(|op| match op {
389                    DrawOp::Glyph(pos, ch, _) => Some((*pos, *ch)),
390                    _ => None,
391                })
392                .collect()
393        }
394    }
395
396    impl Painter for RecordingPainter {
397        fn clear(&mut self, color: EngineRgba) {
398            self.ops.push(DrawOp::Clear(color));
399        }
400
401        fn draw_text_box(&mut self, rect: TileRect, color: EngineRgba) {
402            self.ops.push(DrawOp::TextBox(rect, color));
403        }
404
405        fn draw_text(&mut self, pos: TilePos, text: &str, color: EngineRgba) {
406            self.ops.push(DrawOp::Text(pos, text.to_string(), color));
407        }
408
409        fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: EngineRgba) {
410            self.ops.push(DrawOp::Glyph(pos, glyph, color));
411        }
412
413        fn draw_pixel_rect(
414            &mut self,
415            px: u32,
416            py: u32,
417            pw: u32,
418            ph: u32,
419            color: EngineRgba,
420        ) {
421            self.ops.push(DrawOp::PixelRect(px, py, pw, ph, color));
422        }
423
424        fn draw_gb_tile(
425            &mut self,
426            pos: TilePos,
427            tile_id: u8,
428            fallback: &str,
429            color: EngineRgba,
430        ) {
431            self.ops
432                .push(DrawOp::GbTile(pos, tile_id, fallback.to_string(), color));
433        }
434    }
435
436    // ── Helpers ────────────────────────────────────────────────────────
437
438    fn make_theme() -> Theme {
439        Theme {
440            bg_color: "#FFFFFF".to_string(),
441            default_font: "default".to_string(),
442            ..Default::default()
443        }
444    }
445
446    fn make_fonts() -> HashMap<String, ()> {
447        HashMap::new()
448    }
449
450    fn make_tilesets() -> HashMap<String, ()> {
451        HashMap::new()
452    }
453
454    fn make_render_ctx<'a>(
455        theme: &'a Theme,
456        fonts: &'a HashMap<String, ()>,
457        tilesets: &'a HashMap<String, ()>,
458    ) -> RenderContext<'a> {
459        RenderContext {
460            screen: "test",
461            theme,
462            fonts,
463            tilesets,
464            images: crate::layout_engine::types::empty_image_registry(),
465        }
466    }
467
468    // ── test_dispatch_border ──────────────────────────────────────────
469
470    #[test]
471    fn test_dispatch_border() {
472        let json = r##"{
473            "schema_version": 1,
474            "screen": "test",
475            "elements": [
476                { "type": "border", "rect": { "tx": 1, "ty": 1, "tw": 10, "th": 5 } }
477            ]
478        }"##;
479        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
480        let ctx = DataContext::new();
481        let theme = make_theme();
482        let fonts = make_fonts();
483        let tilesets = make_tilesets();
484        let rc = make_render_ctx(&theme, &fonts, &tilesets);
485        let registry = ElementRegistry::new();
486        let mut painter = RecordingPainter::new();
487
488        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
489
490        // Border draws a Game Boy-style text box (corners, edges, fill)
491        assert!(
492            painter.has_op(|op| matches!(op, DrawOp::TextBox(..))),
493            "border should draw a text box"
494        );
495    }
496
497    // ── test_dispatch_text ────────────────────────────────────────────
498
499    #[test]
500    fn test_dispatch_text() {
501        let json = r##"{
502            "schema_version": 1,
503            "screen": "test",
504            "elements": [
505                { "type": "text", "rect": { "tx": 2, "ty": 3, "tw": 10, "th": 2 }, "value": "Hello" }
506            ]
507        }"##;
508        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
509        let ctx = DataContext::new();
510        let theme = make_theme();
511        let fonts = make_fonts();
512        let tilesets = make_tilesets();
513        let rc = make_render_ctx(&theme, &fonts, &tilesets);
514        let registry = ElementRegistry::new();
515        let mut painter = RecordingPainter::new();
516
517        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
518
519        let glyphs = painter.glyphs();
520        assert!(!glyphs.is_empty(), "text should draw glyphs");
521        assert_eq!(glyphs[0].0, TilePos::new(2, 3));
522        assert_eq!(glyphs[0].1, 'H');
523    }
524
525    // ── test_dispatch_tile ────────────────────────────────────────────
526
527    #[test]
528    fn test_dispatch_tile() {
529        let json = r##"{
530            "schema_version": 1,
531            "screen": "test",
532            "elements": [
533                { "type": "tile", "rect": { "tx": 5, "ty": 5, "tw": 1, "th": 1 }, "tile_id": 42 }
534            ]
535        }"##;
536        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
537        let ctx = DataContext::new();
538        let theme = make_theme();
539        let fonts = make_fonts();
540        let tilesets = make_tilesets();
541        let rc = make_render_ctx(&theme, &fonts, &tilesets);
542        let registry = ElementRegistry::new();
543        let mut painter = RecordingPainter::new();
544
545        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
546
547        let tiles = painter.gb_tiles();
548        assert_eq!(tiles.len(), 1);
549        assert_eq!(tiles[0], (TilePos::new(5, 5), 42));
550    }
551
552    // ── test_dispatch_group ───────────────────────────────────────────
553
554    #[test]
555    fn test_dispatch_group() {
556        let json = r##"{
557            "schema_version": 1,
558            "screen": "test",
559            "elements": [
560                {
561                    "type": "group",
562                    "rect": { "tx": 2, "ty": 2, "tw": 16, "th": 14 },
563                    "layout": { "gap": 0 },
564                    "clip": false,
565                    "children": [
566                        { "type": "text", "rect": { "tx": 0, "ty": 0, "tw": 5, "th": 1 }, "value": "A" },
567                        { "type": "text", "rect": { "tx": 1, "ty": 1, "tw": 5, "th": 1 }, "value": "B" }
568                    ]
569                }
570            ]
571        }"##;
572        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
573        let ctx = DataContext::new();
574        let theme = make_theme();
575        let fonts = make_fonts();
576        let tilesets = make_tilesets();
577        let rc = make_render_ctx(&theme, &fonts, &tilesets);
578        let registry = ElementRegistry::new();
579        let mut painter = RecordingPainter::new();
580
581        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
582
583        let glyphs = painter.glyphs();
584
585        // Child "A" should be at group origin: (2+0, 2+0) = (2, 2)
586        let a_glyph = glyphs.iter().find(|(_, ch)| *ch == 'A');
587        assert!(a_glyph.is_some(), "'A' should be rendered");
588        assert_eq!(a_glyph.unwrap().0, TilePos::new(2, 2));
589
590        // Child "B" should be at (2+1, 2+1) = (3, 3)
591        let b_glyph = glyphs.iter().find(|(_, ch)| *ch == 'B');
592        assert!(b_glyph.is_some(), "'B' should be rendered");
593        assert_eq!(b_glyph.unwrap().0, TilePos::new(3, 3));
594    }
595
596    // ── test_dispatch_invisible_element ───────────────────────────────
597
598    #[test]
599    fn test_dispatch_invisible_element() {
600        let json = r##"{
601            "schema_version": 1,
602            "screen": "test",
603            "elements": [
604                { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "hidden", "visible": false },
605                { "type": "text", "rect": { "tx": 0, "ty": 1 }, "value": "visible" }
606            ]
607        }"##;
608        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
609        let ctx = DataContext::new();
610        let theme = make_theme();
611        let fonts = make_fonts();
612        let tilesets = make_tilesets();
613        let rc = make_render_ctx(&theme, &fonts, &tilesets);
614        let registry = ElementRegistry::new();
615        let mut painter = RecordingPainter::new();
616
617        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
618
619        let glyphs = painter.glyphs();
620        let hidden = glyphs.iter().any(|(_, ch)| *ch == 'h');
621        assert!(!hidden, "invisible element should not render");
622        let visible = glyphs.iter().any(|(_, ch)| *ch == 'v');
623        assert!(visible, "visible element should render");
624    }
625
626    // ── test_dispatch_unknown_type ────────────────────────────────────
627
628    #[test]
629    fn test_dispatch_unknown_type() {
630        let json = r##"{
631            "schema_version": 1,
632            "screen": "test",
633            "elements": [
634                { "type": "fantasy_element", "rect": { "tx": 0, "ty": 0 } }
635            ]
636        }"##;
637        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
638        let ctx = DataContext::new();
639        let theme = make_theme();
640        let fonts = make_fonts();
641        let tilesets = make_tilesets();
642        let rc = make_render_ctx(&theme, &fonts, &tilesets);
643        let registry = ElementRegistry::new();
644        let mut painter = RecordingPainter::new();
645
646        let result = render_layout(&layout, &ctx, &rc, &registry, &mut painter);
647        assert!(result.is_ok(), "unknown type should not cause error");
648
649        // Only the clear op should be present
650        let non_clear_ops: Vec<_> = painter
651            .ops
652            .iter()
653            .filter(|op| !matches!(op, DrawOp::Clear(_)))
654            .collect();
655        assert!(non_clear_ops.is_empty(), "unknown type should skip rendering");
656    }
657
658    // ── test_dispatch_custom ──────────────────────────────────────────
659
660    #[test]
661    fn test_dispatch_custom() {
662        use crate::layout_engine::registry::CustomElement;
663
664        #[derive(Debug)]
665        struct TestCustom;
666
667        impl CustomElement for TestCustom {
668            fn element_type(&self) -> &'static str {
669                "custom:test"
670            }
671
672            fn render(
673                &self,
674                _element: &LayoutElement,
675                _ctx: &DataContext,
676                _render_ctx: &RenderContext,
677                painter: &mut dyn Painter,
678            ) -> Result<(), RenderError> {
679                painter.draw_glyph(TilePos::new(99, 99), 'X', EngineRgba::INK_BLACK);
680                Ok(())
681            }
682        }
683
684        let json = r##"{
685            "schema_version": 1,
686            "screen": "test",
687            "elements": [
688                { "type": "custom:test", "rect": { "tx": 0, "ty": 0 } }
689            ]
690        }"##;
691        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
692        let ctx = DataContext::new();
693        let theme = make_theme();
694        let fonts = make_fonts();
695        let tilesets = make_tilesets();
696        let rc = make_render_ctx(&theme, &fonts, &tilesets);
697        let mut registry = ElementRegistry::new();
698        registry.register(Box::new(TestCustom));
699        let mut painter = RecordingPainter::new();
700
701        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
702
703        let glyphs = painter.glyphs();
704        assert_eq!(glyphs.len(), 1);
705        assert_eq!(glyphs[0], (TilePos::new(99, 99), 'X'));
706    }
707
708    // ── test_dispatch_z_order ─────────────────────────────────────────
709
710    #[test]
711    fn test_dispatch_z_order() {
712        // Lower z_index draws first, so higher z_index elements appear
713        // "on top" in the ops list.
714        let json = r##"{
715            "schema_version": 1,
716            "screen": "test",
717            "elements": [
718                { "type": "tile", "rect": { "tx": 0, "ty": 0, "tw": 1, "th": 1 }, "tile_id": 99, "z_index": 0 },
719                { "type": "tile", "rect": { "tx": 0, "ty": 0, "tw": 1, "th": 1 }, "tile_id": 1, "z_index": 10 },
720                { "type": "tile", "rect": { "tx": 0, "ty": 0, "tw": 1, "th": 1 }, "tile_id": 50, "z_index": 5 }
721            ]
722        }"##;
723        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
724        let ctx = DataContext::new();
725        let theme = make_theme();
726        let fonts = make_fonts();
727        let tilesets = make_tilesets();
728        let rc = make_render_ctx(&theme, &fonts, &tilesets);
729        let registry = ElementRegistry::new();
730        let mut painter = RecordingPainter::new();
731
732        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
733
734        let tiles = painter.gb_tiles();
735        // Sorted by z_index: 0 (99), 5 (50), 10 (1)
736        assert_eq!(tiles.len(), 3);
737        assert_eq!(tiles[0].1, 99);
738        assert_eq!(tiles[1].1, 50);
739        assert_eq!(tiles[2].1, 1);
740    }
741
742    // ── test_render_title_screen ──────────────────────────────────────
743
744    #[test]
745    fn test_render_title_screen() {
746        let json = r##"{
747            "schema_version": 1,
748            "screen": "title_screen",
749            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
750            "elements": [
751                {
752                    "type": "border",
753                    "rect": { "tx": 1, "ty": 5, "tw": 18, "th": 7 },
754                    "style": "Single"
755                },
756                {
757                    "type": "text",
758                    "rect": { "tx": 5, "ty": 7, "tw": 10, "th": 1 },
759                    "value": "MONSTER RED",
760                    "color": "black",
761                    "align": "Center"
762                },
763                {
764                    "type": "text",
765                    "rect": { "tx": 6, "ty": 9, "tw": 8, "th": 1 },
766                    "value": "Press START",
767                    "color": "darkgray",
768                    "align": "Center"
769                }
770            ]
771        }"##;
772        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
773        let ctx = DataContext::new();
774        let theme = layout.theme.clone();
775        let fonts = make_fonts();
776        let tilesets = make_tilesets();
777        let rc = make_render_ctx(&theme, &fonts, &tilesets);
778        let registry = ElementRegistry::new();
779        let mut painter = RecordingPainter::new();
780
781        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
782
783        // Clear op fired
784        assert!(painter.has_op(|op| matches!(op, DrawOp::Clear(Rgba::INK_WHITE))));
785
786        // Border draws a Game Boy-style text box
787        assert!(painter.has_op(|op| matches!(op, DrawOp::TextBox(..))));
788
789        // Text draws glyphs
790        let glyphs = painter.glyphs();
791        assert!(!glyphs.is_empty(), "title screen should have text glyphs");
792    }
793
794    // ── test_dispatch_divider ─────────────────────────────────────────
795
796    #[test]
797    fn test_dispatch_divider() {
798        let json = r##"{
799            "schema_version": 1,
800            "screen": "test",
801            "elements": [
802                {
803                    "type": "divider",
804                    "rect": { "tx": 0, "ty": 0, "tw": 5, "th": 1 },
805                    "tiles": [120, 121],
806                    "repeat": 3
807                }
808            ]
809        }"##;
810        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
811        let ctx = DataContext::new();
812        let theme = make_theme();
813        let fonts = make_fonts();
814        let tilesets = make_tilesets();
815        let rc = make_render_ctx(&theme, &fonts, &tilesets);
816        let registry = ElementRegistry::new();
817        let mut painter = RecordingPainter::new();
818
819        render_layout(&layout, &ctx, &rc, &registry, &mut painter).unwrap();
820
821        let tiles = painter.gb_tiles();
822        assert_eq!(tiles.len(), 5);
823        assert_eq!(tiles[0].1, 120);
824        assert_eq!(tiles[1].1, 121);
825        // Remaining repeated tiles
826        assert_eq!(tiles[2].1, 121);
827        assert_eq!(tiles[3].1, 121);
828        assert_eq!(tiles[4].1, 121);
829    }
830
831    // ── test_dispatch_image ───────────────────────────────────────────
832
833    #[test]
834    fn test_dispatch_image() {
835        let json = r##"{
836            "schema_version": 1,
837            "screen": "test",
838            "elements": [
839                {
840                    "type": "image",
841                    "rect": { "tx": 1, "ty": 1, "tw": 3, "th": 3 },
842                    "source": "sparkit"
843                }
844            ]
845        }"##;
846        let layout: ScreenLayout = serde_json::from_str(json).unwrap();
847        let ctx = DataContext::new();
848        let theme = make_theme();
849        let fonts = make_fonts();
850        let tilesets = make_tilesets();
851        let rc = make_render_ctx(&theme, &fonts, &tilesets);
852        let registry = ElementRegistry::new();
853        let mut painter = RecordingPainter::new();
854
855        let result = render_layout(&layout, &ctx, &rc, &registry, &mut painter);
856        assert!(result.is_ok(), "image dispatch should not error");
857
858        // Image with no sprite data draws a placeholder (pixel rects)
859        assert!(
860            painter.has_op(|op| matches!(op, DrawOp::PixelRect(..))),
861            "image should draw placeholder"
862        );
863    }
864
865    // ── test_parse_bg_color ───────────────────────────────────────────
866
867    #[test]
868    fn test_parse_bg_color_white() {
869        assert_eq!(parse_bg_color("#FFFFFF"), Rgba::INK_WHITE);
870        assert_eq!(parse_bg_color("white"), Rgba::INK_WHITE);
871    }
872
873    #[test]
874    fn test_parse_bg_color_black() {
875        assert_eq!(parse_bg_color("#000000"), Rgba::INK_BLACK);
876        assert_eq!(parse_bg_color("black"), Rgba::INK_BLACK);
877    }
878
879    #[test]
880    fn test_parse_bg_color_darkgray() {
881        assert_eq!(parse_bg_color("#808080"), Rgba::INK_DARK_GRAY);
882        assert_eq!(parse_bg_color("darkgray"), Rgba::INK_DARK_GRAY);
883    }
884
885    #[test]
886    fn test_parse_bg_color_unknown() {
887        assert_eq!(parse_bg_color("pink"), Rgba::INK_WHITE);
888    }
889}