Skip to main content

dotzuki_renderer/layout_engine/elements/
image.rs

1//! Image element — blits a full-colour RGBA image into the element's rect.
2//!
3//! The `source` field in [`ImageParams`] is a template string (e.g.
4//! `"{portrait}"`, or a literal key) resolved via [`DataContext::resolve`]. The
5//! resolved key is looked up in [`RenderContext::images`] (an [`ImageRegistry`]
6//! the consumer populates). When found, the image is nearest-neighbour scaled to
7//! fit the rect's pixel box (aspect preserved, centred), honouring `flip_x` /
8//! `flip_y`, with transparent pixels skipped.
9//!
10//! When the key is empty/unknown (e.g. the registry has no entry yet), a striped
11//! placeholder rectangle is drawn instead — useful as an "image goes here" cue in
12//! the layout editor. (`palette` is a GB-tileset concept and is ignored here.)
13
14use crate::layout_engine::types::{
15    DataContext, ImageParams, LayoutElement, RenderContext, RenderError,
16};
17use dotzuki_engine::render::painter::Painter;
18use dotzuki_engine::render::Rgba;
19use dotzuki_engine::render::TilePos;
20
21/// Render an image element.
22///
23/// # Sprite data lookup
24///
25/// The `source` template is resolved via [`DataContext::resolve`], producing
26/// a lookup key. This key is used to find a matching tileset in
27/// `render_ctx.tilesets`. When found, the tileset's tiles are drawn row by
28/// row at the element rect position, applying `flip_x`, `flip_y`, and
29/// `palette` as specified in the params.
30///
31/// # Placeholder (no sprite data)
32///
33/// If the resolved key does not match any tileset, a striped placeholder
34/// rectangle is drawn. The stripes alternate between `LightGray` and
35/// `DarkGray` in a checkerboard pattern, visually indicating "image not found".
36pub fn render_image(
37    element: &LayoutElement,
38    params: &ImageParams,
39    ctx: &DataContext,
40    render_ctx: &RenderContext,
41    painter: &mut dyn Painter,
42) -> Result<(), RenderError> {
43    let rect = &element.rect;
44    let tw = rect.tw.unwrap_or(1);
45    let th = rect.th.unwrap_or(1);
46    let source_key = ctx.resolve(&params.source);
47    let tx = rect.tx.resolve(ctx);
48    let ty = rect.ty.resolve(ctx);
49
50    if source_key.is_empty() || source_key == "?" {
51        draw_placeholder(tx, ty, tw, th, painter);
52        return Ok(());
53    }
54
55    // Look the resolved key up in the full-colour image registry. (`palette` is a
56    // GB-tileset concept and does not apply to RGBA images, so it is ignored.)
57    match render_ctx.images.get(&source_key) {
58        Some(img) if !img.is_empty() => {
59            // Fit the image inside the rect's pixel box, preserving aspect, centred.
60            let (box_px, box_py) = TilePos::new(tx, ty).to_pixels();
61            let box_w = tw * 8;
62            let box_h = th * 8;
63            let scale =
64                (box_w as f32 / img.width as f32).min(box_h as f32 / img.height as f32);
65            let dst_w = ((img.width as f32 * scale).round() as u32).max(1);
66            let dst_h = ((img.height as f32 * scale).round() as u32).max(1);
67            let ox = box_px + (box_w.saturating_sub(dst_w)) / 2;
68            let oy = box_py + (box_h.saturating_sub(dst_h)) / 2;
69            painter.draw_rgba(
70                ox,
71                oy,
72                dst_w,
73                dst_h,
74                &img.pixels,
75                img.width,
76                img.height,
77                params.flip_x,
78                params.flip_y,
79            );
80        }
81        // Unknown key (or empty image) → striped "image not found" placeholder.
82        _ => draw_placeholder(tx, ty, tw, th, painter),
83    }
84    Ok(())
85}
86
87fn draw_placeholder(tx: u32, ty: u32, tw: u32, th: u32, painter: &mut dyn Painter) {
88    for row in 0..th {
89        for col in 0..tw {
90            let color = if (col + row) % 2 == 0 {
91                Rgba::INK_LIGHT_GRAY
92            } else {
93                Rgba::INK_DARK_GRAY
94            };
95            let (px, py) = TilePos::new(tx + col, ty + row).to_pixels();
96            painter.draw_pixel_rect(px, py, 8, 8, color);
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::layout_engine::types::{Coord, ElementParams, ElementRect};
105    use crate::layout_engine::types::DataValue;
106    use dotzuki_engine::render::Rgba as EngineRgba;
107
108    #[derive(Debug, Default)]
109    struct RecordingPainter {
110        pixel_rects: Vec<(u32, u32, u32, u32, EngineRgba)>,
111        tile_calls: Vec<(u32, u32, u8)>,
112    }
113
114    impl Painter for RecordingPainter {
115        fn clear(&mut self, _color: EngineRgba) {}
116
117        fn draw_text_box(
118            &mut self,
119            _rect: dotzuki_engine::render::TileRect,
120            _color: EngineRgba,
121        ) {
122        }
123
124        fn draw_text(&mut self, _pos: TilePos, _text: &str, _color: EngineRgba) {}
125
126        fn draw_glyph(&mut self, _pos: TilePos, _glyph: char, _color: EngineRgba) {}
127
128        fn draw_pixel_rect(
129            &mut self,
130            px: u32,
131            py: u32,
132            pw: u32,
133            ph: u32,
134            color: EngineRgba,
135        ) {
136            self.pixel_rects.push((px, py, pw, ph, color));
137        }
138
139        fn draw_gb_tile(
140            &mut self,
141            pos: TilePos,
142            tile_id: u8,
143            _fallback: &str,
144            _color: EngineRgba,
145        ) {
146            self.tile_calls.push((pos.tx, pos.ty, tile_id));
147        }
148    }
149
150    fn make_element(rect: ElementRect, params: ImageParams) -> LayoutElement {
151        LayoutElement {
152            id: String::new(),
153            element_type: "image".to_string(),
154            rect,
155            visible: crate::layout_engine::types::Visibility::Static(true),
156            z_index: 0,
157            params: ElementParams::Image(params),
158        }
159    }
160
161    fn make_render_ctx() -> (
162        RenderContext<'static>,
163        std::collections::HashMap<String, ()>,
164        std::collections::HashMap<String, ()>,
165    ) {
166        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
167        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
168        let theme = crate::layout_engine::types::Theme::default();
169        // SAFETY: We leak the box to get a 'static reference. In tests this is fine.
170        let fonts_ref: &'static std::collections::HashMap<String, ()> =
171            Box::leak(Box::new(fonts.clone()));
172        let tilesets_ref: &'static std::collections::HashMap<String, ()> =
173            Box::leak(Box::new(tilesets.clone()));
174        let theme_ref: &'static crate::layout_engine::types::Theme =
175            Box::leak(Box::new(theme));
176        (
177            RenderContext {
178                screen: "test",
179                theme: theme_ref,
180                fonts: fonts_ref,
181                tilesets: tilesets_ref,
182                images: crate::layout_engine::types::empty_image_registry(),
183            },
184            fonts,
185            tilesets,
186        )
187    }
188
189    #[test]
190    fn resolves_source_from_datacontext() {
191        let rect = ElementRect {
192            tx: Coord::Literal(0),
193            ty: Coord::Literal(0),
194            tw: Some(1),
195            th: Some(1),
196        };
197        let params = ImageParams {
198            source: "{sprite}".to_string(),
199            flip_x: false,
200            flip_y: false,
201            palette: None,
202        };
203        let elem = make_element(rect, params);
204        let mut ctx = DataContext::new();
205        ctx.set("sprite", "leafkit_front");
206        let (rc, _fonts, _tilesets) = make_render_ctx();
207
208        let mut painter = RecordingPainter::default();
209        render_image(
210            &elem,
211            if let ElementParams::Image(ref p) = elem.params {
212                p
213            } else {
214                unreachable!()
215            },
216            &ctx,
217            &rc,
218            &mut painter,
219        )
220        .unwrap();
221
222        assert!(!painter.pixel_rects.is_empty());
223    }
224
225    #[test]
226    fn unknown_source_draws_placeholder() {
227        let rect = ElementRect {
228            tx: Coord::Literal(0),
229            ty: Coord::Literal(0),
230            tw: Some(1),
231            th: Some(1),
232        };
233        let params = ImageParams {
234            source: "{missing}".to_string(),
235            flip_x: false,
236            flip_y: false,
237            palette: None,
238        };
239        let elem = make_element(rect, params);
240        let ctx = DataContext::new();
241        let (rc, _fonts, _tilesets) = make_render_ctx();
242
243        let mut painter = RecordingPainter::default();
244        render_image(
245            &elem,
246            if let ElementParams::Image(ref p) = elem.params {
247                p
248            } else {
249                unreachable!()
250            },
251            &ctx,
252            &rc,
253            &mut painter,
254        )
255        .unwrap();
256
257        assert!(!painter.pixel_rects.is_empty());
258    }
259
260    #[test]
261    fn placeholder_spans_full_rect() {
262        let rect = ElementRect {
263            tx: Coord::Literal(2),
264            ty: Coord::Literal(1),
265            tw: Some(3),
266            th: Some(2),
267        };
268        let params = ImageParams {
269            source: "{sprite}".to_string(),
270            flip_x: false,
271            flip_y: false,
272            palette: None,
273        };
274        let elem = make_element(rect, params);
275        let mut ctx = DataContext::new();
276        ctx.set("sprite", DataValue::Str("unknown".to_string()));
277        let (rc, _fonts, _tilesets) = make_render_ctx();
278
279        let mut painter = RecordingPainter::default();
280        render_image(
281            &elem,
282            if let ElementParams::Image(ref p) = elem.params {
283                p
284            } else {
285                unreachable!()
286            },
287            &ctx,
288            &rc,
289            &mut painter,
290        )
291        .unwrap();
292
293        assert_eq!(painter.pixel_rects.len(), 6);
294        for (px, py, pw, ph, _) in &painter.pixel_rects {
295            assert_eq!(*pw, 8);
296            assert_eq!(*ph, 8);
297            assert!(*px >= 16 && *px < 40);
298            assert!(*py >= 8 && *py < 24);
299        }
300    }
301
302    #[test]
303    fn placeholder_alternates_colors() {
304        let rect = ElementRect {
305            tx: Coord::Literal(0),
306            ty: Coord::Literal(0),
307            tw: Some(2),
308            th: Some(2),
309        };
310        let params = ImageParams {
311            source: "{sprite}".to_string(),
312            flip_x: false,
313            flip_y: false,
314            palette: None,
315        };
316        let elem = make_element(rect, params);
317        let mut ctx = DataContext::new();
318        ctx.set("sprite", "missing");
319        let (rc, _fonts, _tilesets) = make_render_ctx();
320
321        let mut painter = RecordingPainter::default();
322        render_image(
323            &elem,
324            if let ElementParams::Image(ref p) = elem.params {
325                p
326            } else {
327                unreachable!()
328            },
329            &ctx,
330            &rc,
331            &mut painter,
332        )
333        .unwrap();
334
335        assert_eq!(painter.pixel_rects.len(), 4);
336        let colors: Vec<EngineRgba> = painter.pixel_rects.iter().map(|r| r.4).collect();
337        assert_eq!(colors[0], EngineRgba::INK_LIGHT_GRAY);
338        assert_eq!(colors[1], EngineRgba::INK_DARK_GRAY);
339        assert_eq!(colors[2], EngineRgba::INK_DARK_GRAY);
340        assert_eq!(colors[3], EngineRgba::INK_LIGHT_GRAY);
341    }
342
343    #[test]
344    fn empty_resolved_source_draws_placeholder() {
345        let rect = ElementRect {
346            tx: Coord::Literal(0),
347            ty: Coord::Literal(0),
348            tw: Some(2),
349            th: Some(2),
350        };
351        let params = ImageParams {
352            source: "{empty}".to_string(),
353            flip_x: false,
354            flip_y: false,
355            palette: None,
356        };
357        let elem = make_element(rect, params);
358        let mut ctx = DataContext::new();
359        ctx.set("empty", "");
360        let (rc, _fonts, _tilesets) = make_render_ctx();
361
362        let mut painter = RecordingPainter::default();
363        render_image(
364            &elem,
365            if let ElementParams::Image(ref p) = elem.params {
366                p
367            } else {
368                unreachable!()
369            },
370            &ctx,
371            &rc,
372            &mut painter,
373        )
374        .unwrap();
375
376        assert!(!painter.pixel_rects.is_empty());
377    }
378
379    #[test]
380    fn flip_params_are_accepted() {
381        let rect = ElementRect {
382            tx: Coord::Literal(0),
383            ty: Coord::Literal(0),
384            tw: Some(1),
385            th: Some(1),
386        };
387        let params = ImageParams {
388            source: "{sprite}".to_string(),
389            flip_x: true,
390            flip_y: true,
391            palette: Some("pal1".to_string()),
392        };
393        let elem = make_element(rect, params);
394        let mut ctx = DataContext::new();
395        ctx.set("sprite", "leafkit");
396        let (rc, _fonts, _tilesets) = make_render_ctx();
397
398        let mut painter = RecordingPainter::default();
399        let result = render_image(
400            &elem,
401            if let ElementParams::Image(ref p) = elem.params {
402                p
403            } else {
404                unreachable!()
405            },
406            &ctx,
407            &rc,
408            &mut painter,
409        );
410        assert!(result.is_ok());
411    }
412
413    #[test]
414    fn default_tw_th_is_1() {
415        let rect = ElementRect {
416            tx: Coord::Literal(5),
417            ty: Coord::Literal(3),
418            tw: None,
419            th: None,
420        };
421        let params = ImageParams {
422            source: "{sprite}".to_string(),
423            flip_x: false,
424            flip_y: false,
425            palette: None,
426        };
427        let elem = make_element(rect, params);
428        let mut ctx = DataContext::new();
429        ctx.set("sprite", "leafkit");
430        let (rc, _fonts, _tilesets) = make_render_ctx();
431
432        let mut painter = RecordingPainter::default();
433        render_image(
434            &elem,
435            if let ElementParams::Image(ref p) = elem.params {
436                p
437            } else {
438                unreachable!()
439            },
440            &ctx,
441            &rc,
442            &mut painter,
443        )
444        .unwrap();
445
446        assert_eq!(painter.pixel_rects.len(), 1);
447        assert_eq!(painter.pixel_rects[0].0, 40);
448        assert_eq!(painter.pixel_rects[0].1, 24);
449        assert_eq!(painter.pixel_rects[0].2, 8);
450        assert_eq!(painter.pixel_rects[0].3, 8);
451    }
452
453    #[test]
454    fn registered_image_is_blitted_not_placeholder() {
455        use crate::layout_engine::types::{ImageData, ImageRegistry, Theme};
456        let rect = ElementRect {
457            tx: Coord::Literal(0),
458            ty: Coord::Literal(0),
459            tw: Some(1),
460            th: Some(1),
461        };
462        let params = ImageParams {
463            source: "{sprite}".to_string(),
464            flip_x: false,
465            flip_y: false,
466            palette: None,
467        };
468        let elem = make_element(rect, params);
469        let mut ctx = DataContext::new();
470        ctx.set("sprite", "hero");
471
472        // A 1×1 opaque red image registered under the resolved key "hero".
473        let red = EngineRgba::new(255, 0, 0, 255);
474        let mut images = ImageRegistry::new();
475        images.insert("hero".to_string(), ImageData::new(1, 1, vec![red]));
476
477        let theme = Theme::default();
478        let fonts = std::collections::HashMap::<String, ()>::new();
479        let tilesets = std::collections::HashMap::<String, ()>::new();
480        let rc = RenderContext {
481            screen: "t",
482            theme: &theme,
483            fonts: &fonts,
484            tilesets: &tilesets,
485            images: &images,
486        };
487
488        let mut painter = RecordingPainter::default();
489        render_image(
490            &elem,
491            if let ElementParams::Image(ref p) = elem.params {
492                p
493            } else {
494                unreachable!()
495            },
496            &ctx,
497            &rc,
498            &mut painter,
499        )
500        .unwrap();
501
502        // The 1×1 image scales to fill the 8×8-px cell → 64 red pixels via the
503        // default per-pixel draw_rgba; none are the placeholder gray.
504        assert_eq!(painter.pixel_rects.len(), 64, "8×8 box filled");
505        assert!(
506            painter.pixel_rects.iter().all(|r| r.4 == red),
507            "every blitted pixel is the image colour, not a placeholder"
508        );
509        assert!(painter.pixel_rects.iter().all(|r| r.2 == 1 && r.3 == 1));
510    }
511
512    #[test]
513    fn unknown_key_with_nonempty_registry_still_placeholders() {
514        use crate::layout_engine::types::{ImageData, ImageRegistry, Theme};
515        let rect = ElementRect {
516            tx: Coord::Literal(0),
517            ty: Coord::Literal(0),
518            tw: Some(2),
519            th: Some(2),
520        };
521        let params = ImageParams {
522            source: "{sprite}".to_string(),
523            flip_x: false,
524            flip_y: false,
525            palette: None,
526        };
527        let elem = make_element(rect, params);
528        let mut ctx = DataContext::new();
529        ctx.set("sprite", "missing");
530
531        let mut images = ImageRegistry::new();
532        images.insert(
533            "other".to_string(),
534            ImageData::new(1, 1, vec![EngineRgba::new(0, 255, 0, 255)]),
535        );
536        let theme = Theme::default();
537        let fonts = std::collections::HashMap::<String, ()>::new();
538        let tilesets = std::collections::HashMap::<String, ()>::new();
539        let rc = RenderContext {
540            screen: "t",
541            theme: &theme,
542            fonts: &fonts,
543            tilesets: &tilesets,
544            images: &images,
545        };
546
547        let mut painter = RecordingPainter::default();
548        render_image(
549            &elem,
550            if let ElementParams::Image(ref p) = elem.params {
551                p
552            } else {
553                unreachable!()
554            },
555            &ctx,
556            &rc,
557            &mut painter,
558        )
559        .unwrap();
560
561        // 2×2 cells of placeholder = 4 draw_pixel_rect calls of 8×8.
562        assert_eq!(painter.pixel_rects.len(), 4);
563        assert!(painter.pixel_rects.iter().all(|r| r.2 == 8 && r.3 == 8));
564    }
565}