Skip to main content

dotzuki_renderer/layout_engine/
types.rs

1use std::collections::HashMap;
2use dotzuki_engine::render::Rgba;
3use serde::de::{self, Deserializer};
4use serde::Deserialize;
5use thiserror::Error;
6
7// ============================================================================
8// Placeholder type aliases (will be filled in later)
9// ============================================================================
10
11pub type FontRegistry = HashMap<String, ()>;
12
13pub type TilesetRegistry = HashMap<String, ()>;
14
15// ============================================================================
16// Image registry — full-colour RGBA images the `image` element blits
17// ============================================================================
18
19/// A decoded full-colour image: straight RGBA, row-major (`width * height`).
20/// The `image` layout element looks one up by its resolved `source` key in the
21/// [`ImageRegistry`] carried on [`RenderContext`].
22#[derive(Clone, Debug, Default)]
23pub struct ImageData {
24    pub width: u32,
25    pub height: u32,
26    pub pixels: Vec<Rgba>,
27}
28
29impl ImageData {
30    pub fn new(width: u32, height: u32, pixels: Vec<Rgba>) -> Self {
31        Self { width, height, pixels }
32    }
33
34    pub fn is_empty(&self) -> bool {
35        self.width == 0 || self.height == 0 || self.pixels.is_empty()
36    }
37
38    /// Pixel at `(x, y)`; transparent if out of range.
39    #[inline]
40    pub fn pixel(&self, x: u32, y: u32) -> Rgba {
41        if x >= self.width || y >= self.height {
42            return Rgba::TRANSPARENT;
43        }
44        self.pixels
45            .get((y * self.width + x) as usize)
46            .copied()
47            .unwrap_or(Rgba::TRANSPARENT)
48    }
49
50    /// Decode an RGBA image from a PNG (etc.) file. Behind the `image-assets`
51    /// feature, mirroring [`crate::walk_sprite::WalkSprite::load`].
52    #[cfg(feature = "image-assets")]
53    pub fn load(path: &std::path::Path) -> Result<Self, String> {
54        let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
55        let img = image::load_from_memory(&bytes)
56            .map_err(|e| format!("decode {}: {e}", path.display()))?
57            .to_rgba8();
58        let (w, h) = img.dimensions();
59        let pixels = img
60            .pixels()
61            .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
62            .collect();
63        Ok(Self::new(w, h, pixels))
64    }
65}
66
67/// Named registry of full-colour images, looked up by an `image` element's
68/// resolved `source` key. Empty by default (see [`empty_image_registry`]).
69pub type ImageRegistry = HashMap<String, ImageData>;
70
71/// A shared empty image registry — the default [`RenderContext::images`] for
72/// callers that render no images (so [`RenderContext::new`] stays 4-arg).
73pub fn empty_image_registry() -> &'static ImageRegistry {
74    static EMPTY: std::sync::OnceLock<ImageRegistry> = std::sync::OnceLock::new();
75    EMPTY.get_or_init(ImageRegistry::new)
76}
77
78// ============================================================================
79// Top-level layout types
80// ============================================================================
81
82#[derive(Debug, Clone, Deserialize)]
83#[non_exhaustive]
84pub struct ScreenLayout {
85    pub schema_version: u8,
86
87    pub screen: String,
88
89    #[serde(default)]
90    pub theme: Theme,
91
92    pub elements: Vec<LayoutElement>,
93}
94
95// ============================================================================
96// Theme
97// ============================================================================
98
99#[derive(Debug, Clone, Deserialize)]
100#[non_exhaustive]
101pub struct Theme {
102    pub bg_color: String,
103
104    #[serde(default = "default_font_name")]
105    pub default_font: String,
106
107    /// How text is positioned. `Tile` (default) keeps the legacy Game Boy 8×8
108    /// per-glyph grid; `Proportional` renders at pixel precision with per-glyph
109    /// advance (for high-resolution, CJK-correct screens). The proportional path
110    /// also requires `Painter::supports_proportional()` — both must hold, so any
111    /// layout without an explicit theme (e.g. all pokered screens) stays on Tile.
112    #[serde(default)]
113    pub text_mode: TextMode,
114
115    /// Default ink colour for text/list/cursor when no element-level `color` is
116    /// set. `None` → the legacy `INK_BLACK`.
117    #[serde(default)]
118    pub ink: Option<String>,
119
120    /// Panel interior fill (for game-themed boxes drawn via `pixel_rect`).
121    #[serde(default)]
122    pub panel_bg: Option<String>,
123
124    /// Panel border colour.
125    #[serde(default)]
126    pub panel_border: Option<String>,
127
128    /// Cursor (▶) colour; `None` → the resolved `ink`.
129    #[serde(default)]
130    pub cursor_color: Option<String>,
131}
132
133/// Text positioning strategy for a screen (see [`Theme::text_mode`]).
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum TextMode {
137    /// Legacy 8×8 tile grid — one glyph per tile cell (Game Boy fonts).
138    #[default]
139    Tile,
140    /// Pixel-precise proportional advance (high-resolution / CJK).
141    Proportional,
142}
143
144fn default_font_name() -> String {
145    "default".to_string()
146}
147
148impl Default for Theme {
149    fn default() -> Self {
150        Self {
151            bg_color: "#FFFFFF".to_string(),
152            default_font: "default".to_string(),
153            text_mode: TextMode::Tile,
154            ink: None,
155            panel_bg: None,
156            panel_border: None,
157            cursor_color: None,
158        }
159    }
160}
161
162impl Theme {
163    /// Resolve the ink colour: `theme.ink` if set, else the legacy `INK_BLACK`.
164    pub fn ink_color(&self) -> dotzuki_engine::render::Rgba {
165        self.ink
166            .as_deref()
167            .map(crate::layout_engine::elements::text::parse_color)
168            .unwrap_or(dotzuki_engine::render::Rgba::INK_BLACK)
169    }
170
171    /// Resolve the cursor colour: `theme.cursor_color`, else `ink`, else `INK_BLACK`.
172    pub fn cursor_ink(&self) -> dotzuki_engine::render::Rgba {
173        self.cursor_color
174            .as_deref()
175            .map(crate::layout_engine::elements::text::parse_color)
176            .unwrap_or_else(|| self.ink_color())
177    }
178
179    /// Whether to use the proportional path given the painter's capability.
180    pub fn proportional(&self, painter_supports: bool) -> bool {
181        self.text_mode == TextMode::Proportional && painter_supports
182    }
183}
184
185// ============================================================================
186// Layout element
187// ============================================================================
188
189#[derive(Debug, Clone, Deserialize)]
190#[non_exhaustive]
191pub struct LayoutElement {
192    #[serde(default)]
193    pub id: String,
194
195    #[serde(rename = "type")]
196    pub element_type: String,
197
198    pub rect: ElementRect,
199
200    #[serde(default)]
201    pub visible: Visibility,
202
203    #[serde(default)]
204    pub z_index: i32,
205
206    #[serde(flatten)]
207    pub params: ElementParams,
208}
209
210// ============================================================================
211// Visibility — static flag or template condition
212// ============================================================================
213
214/// Whether an element should render. Either a static `bool` or a
215/// `{template}` condition (e.g. `"{show_entry}"`) evaluated against the
216/// [`DataContext`] at render time (truthy → visible).
217#[derive(Debug, Clone)]
218pub enum Visibility {
219    /// Static visibility flag.
220    Static(bool),
221    /// Template condition string; resolved variable's truthiness decides.
222    Template(String),
223}
224
225impl Default for Visibility {
226    fn default() -> Self {
227        Visibility::Static(true)
228    }
229}
230
231impl Visibility {
232    /// Evaluate whether the element should be rendered under `ctx`.
233    pub fn eval(&self, ctx: &DataContext) -> bool {
234        match self {
235            Visibility::Static(b) => *b,
236            Visibility::Template(t) => {
237                let trimmed = t.trim();
238                let key = trimmed
239                    .strip_prefix('{')
240                    .and_then(|r| r.strip_suffix('}'))
241                    .unwrap_or(trimmed)
242                    .trim();
243                ctx.is_truthy(key)
244            }
245        }
246    }
247}
248
249impl<'de> Deserialize<'de> for Visibility {
250    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
251        struct VisibilityVisitor;
252
253        impl<'de> serde::de::Visitor<'de> for VisibilityVisitor {
254            type Value = Visibility;
255
256            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
257                f.write_str("a bool or a {template} condition string")
258            }
259
260            fn visit_bool<E: de::Error>(self, b: bool) -> Result<Visibility, E> {
261                Ok(Visibility::Static(b))
262            }
263
264            fn visit_str<E: de::Error>(self, s: &str) -> Result<Visibility, E> {
265                match s.trim().to_lowercase().as_str() {
266                    "true" => Ok(Visibility::Static(true)),
267                    "false" => Ok(Visibility::Static(false)),
268                    // `{var}` template or a bare variable name → condition
269                    _ => Ok(Visibility::Template(s.to_string())),
270                }
271            }
272        }
273
274        d.deserialize_any(VisibilityVisitor)
275    }
276}
277
278// ============================================================================
279// Coord — template-variable-aware coordinate
280// ============================================================================
281
282/// A coordinate value that can be either a literal `u32` or a template variable
283/// string (e.g. `"{cursor_0_tx}"`) that must be resolved at render time via
284/// [`DataContext`].
285#[derive(Debug, Clone)]
286pub enum Coord {
287    /// A literal numeric coordinate.
288    Literal(u32),
289    /// A template variable string (including braces), e.g. `"{cursor_y}"`.
290    Template(String),
291}
292
293impl Coord {
294    /// Resolve the coordinate to a `u32`.
295    ///
296    /// Literal values are returned directly. Template strings are resolved via
297    /// [`DataContext::resolve`] and parsed as `u32`. Falls back to `0` if the
298    /// variable is missing or unparseable.
299    pub fn resolve(&self, ctx: &DataContext) -> u32 {
300        match self {
301            Coord::Literal(v) => *v,
302            Coord::Template(tpl) => {
303                let resolved = ctx.resolve(tpl);
304                resolved.trim().parse::<u32>().unwrap_or(0)
305            }
306        }
307    }
308
309    /// Returns the literal value if this is a [`Coord::Literal`], or `None`
310    /// if it's a template that needs context to resolve.
311    pub fn as_literal(&self) -> Option<u32> {
312        match self {
313            Coord::Literal(v) => Some(*v),
314            Coord::Template(_) => None,
315        }
316    }
317}
318
319impl From<u32> for Coord {
320    fn from(v: u32) -> Self {
321        Coord::Literal(v)
322    }
323}
324
325impl Default for Coord {
326    fn default() -> Self {
327        Coord::Literal(0)
328    }
329}
330
331impl<'de> Deserialize<'de> for Coord {
332    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
333        struct CoordVisitor;
334
335        impl<'de> serde::de::Visitor<'de> for CoordVisitor {
336            type Value = Coord;
337
338            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
339                f.write_str("a u32 number or a {template} string")
340            }
341
342            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
343                Ok(Coord::Literal(v as u32))
344            }
345
346            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
347                if v < 0 {
348                    return Err(de::Error::custom(format!(
349                        "negative coordinate {} not allowed", v
350                    )));
351                }
352                Ok(Coord::Literal(v as u32))
353            }
354
355            fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
356                if v.contains('{') {
357                    // Template variable — store as-is for runtime resolution
358                    Ok(Coord::Template(v.to_string()))
359                } else {
360                    // Plain numeric string
361                    v.parse::<u32>()
362                        .map(Coord::Literal)
363                        .map_err(de::Error::custom)
364                }
365            }
366        }
367
368        d.deserialize_any(CoordVisitor)
369    }
370}
371
372// ============================================================================
373// ElementRect
374// ============================================================================
375
376#[derive(Debug, Clone, Deserialize)]
377#[non_exhaustive]
378pub struct ElementRect {
379    #[serde(default)]
380    pub tx: Coord,
381
382    #[serde(default)]
383    pub ty: Coord,
384
385    #[serde(default)]
386    pub tw: Option<u32>,
387
388    #[serde(default)]
389    pub th: Option<u32>,
390}
391
392// ============================================================================
393// Element parameters (untagged union)
394// ============================================================================
395
396#[derive(Debug, Clone, Deserialize)]
397#[serde(untagged)]
398pub enum ElementParams {
399    Border(BorderParams),
400
401    Text(TextParams),
402
403    Tile(TileParams),
404
405    Divider(DividerParams),
406
407    Image(ImageParams),
408
409    List(ListParams),
410
411    FlexList(FlexListParams),
412
413    Group(GroupParams),
414
415    Cursor(CursorParams),
416
417    Bracket(BracketParams),
418
419    PixelRect(PixelRectParams),
420
421    Custom(serde_json::Value),
422}
423
424// ============================================================================
425// Primitive params (bracket / pixel_rect)
426// ============================================================================
427
428/// A partial box border (left/right/top/bottom edges) drawn at the element's
429/// `rect`, 1px lines — the original "bracket" decoration used on stats pages.
430#[derive(Debug, Clone, Deserialize)]
431#[serde(deny_unknown_fields)]
432#[non_exhaustive]
433pub struct BracketParams {
434    pub color: Option<String>,
435    #[serde(default)]
436    pub left: bool,
437    #[serde(default)]
438    pub right: bool,
439    #[serde(default)]
440    pub top: bool,
441    #[serde(default)]
442    pub bottom: bool,
443    #[serde(default)]
444    pub with_arrow: bool,
445}
446
447/// A raw filled rectangle in PIXEL coordinates (not tiles).
448#[derive(Debug, Clone, Deserialize)]
449#[serde(deny_unknown_fields)]
450#[non_exhaustive]
451pub struct PixelRectParams {
452    pub color: Option<String>,
453    pub px: u32,
454    pub py: u32,
455    pub pw: u32,
456    pub ph: u32,
457}
458
459// ============================================================================
460// CursorParams
461// ============================================================================
462
463/// A selection cursor (e.g. the ▶ arrow). Its base position is the element's
464/// `rect.tx`/`rect.ty`; the final position is
465/// `base + col*col_step` (x) and `base + row*row_step` (y), so a single cursor
466/// expresses a 1-D list, a 2-D grid (battle FIGHT/PKMN/ITEM/RUN), or an
467/// enum-offset selector. `col`/`row` are data bindings; place several cursor
468/// elements for multi-cursor screens (options/party).
469#[derive(Debug, Clone, Deserialize)]
470#[serde(deny_unknown_fields)]
471#[non_exhaustive]
472pub struct CursorParams {
473    /// Cursor glyph; defaults to ▶ when absent/empty.
474    #[serde(default)]
475    pub glyph: Option<String>,
476
477    pub color: Option<String>,
478
479    /// Column index (literal or `{template}`); multiplied by `col_step`.
480    #[serde(default)]
481    pub col: Coord,
482
483    /// Row index (literal or `{template}`); multiplied by `row_step`.
484    #[serde(default)]
485    pub row: Coord,
486
487    /// Tiles to advance per column.
488    #[serde(default)]
489    pub col_step: u32,
490
491    /// Tiles to advance per row.
492    #[serde(default)]
493    pub row_step: u32,
494}
495
496impl CursorParams {
497    /// The glyph char, defaulting to ▶ (U+25B6).
498    pub fn glyph_char(&self) -> char {
499        self.glyph
500            .as_deref()
501            .and_then(|g| g.chars().next())
502            .unwrap_or('\u{25B6}')
503    }
504}
505
506// ============================================================================
507// BorderParams
508// ============================================================================
509
510#[derive(Debug, Clone, Deserialize)]
511#[serde(deny_unknown_fields)]
512#[non_exhaustive]
513pub struct BorderParams {
514    #[serde(default, deserialize_with = "deserialize_optional_border_style")]
515    pub style: Option<BorderStyle>,
516
517    pub tileset: Option<String>,
518
519    /// Elements nested inside the panel (e.g. a text box's contents). Their
520    /// rects are absolute screen coordinates — unlike `group`, a border does
521    /// not apply layout/offset to its children, it just draws the box and then
522    /// renders each child in place.
523    #[serde(default)]
524    pub children: Vec<LayoutElement>,
525}
526
527#[derive(Debug, Clone, Default, Deserialize)]
528pub enum BorderStyle {
529    #[default]
530    Single,
531
532    Double,
533}
534
535fn deserialize_optional_border_style<'de, D>(d: D) -> Result<Option<BorderStyle>, D::Error>
536where
537    D: Deserializer<'de>,
538{
539    // Accept: a style string ("default"/"single"/"double"), an object form
540    // (custom per-corner tile ids — treated as Single; the framebuffer painter
541    // draws the default box), or null.
542    let value = serde_json::Value::deserialize(d)?;
543    match value {
544        serde_json::Value::Null => Ok(None),
545        serde_json::Value::String(s) => match s.to_lowercase().as_str() {
546            "default" | "single" => Ok(Some(BorderStyle::Single)),
547            "double" => Ok(Some(BorderStyle::Double)),
548            other => Err(de::Error::custom(format!("unknown border style: {other}"))),
549        },
550        serde_json::Value::Object(_) => Ok(Some(BorderStyle::Single)),
551        other => Err(de::Error::custom(format!(
552            "border style must be a string or object, got {other}"
553        ))),
554    }
555}
556
557// ============================================================================
558// TextParams
559// ============================================================================
560
561/// A text value that is either a single string (same in every language) or a
562/// per-locale map produced by the GUI DSL's `@t("en", "中文")` literal —
563/// `{"en": "TEXT SPEED", "zh": "文字速度"}`. The renderer resolves it against
564/// the active language via [`LocalizedValue::get`].
565#[derive(Debug, Clone, Deserialize)]
566#[serde(untagged)]
567pub enum LocalizedValue {
568    Plain(String),
569    Localized(std::collections::BTreeMap<String, String>),
570}
571
572impl LocalizedValue {
573    /// Text for `lang`, falling back to `en`, then any present locale, else "".
574    pub fn get(&self, lang: &str) -> &str {
575        match self {
576            LocalizedValue::Plain(s) => s,
577            LocalizedValue::Localized(map) => map
578                .get(lang)
579                .or_else(|| map.get("en"))
580                .or_else(|| map.values().next())
581                .map(|s| s.as_str())
582                .unwrap_or(""),
583        }
584    }
585}
586
587impl Default for LocalizedValue {
588    fn default() -> Self {
589        LocalizedValue::Plain(String::new())
590    }
591}
592
593impl From<&str> for LocalizedValue {
594    fn from(s: &str) -> Self {
595        LocalizedValue::Plain(s.to_string())
596    }
597}
598
599impl From<String> for LocalizedValue {
600    fn from(s: String) -> Self {
601        LocalizedValue::Plain(s)
602    }
603}
604
605#[derive(Debug, Clone, Deserialize)]
606#[serde(deny_unknown_fields)]
607#[non_exhaustive]
608pub struct TextParams {
609    pub value: LocalizedValue,
610
611    pub format: Option<String>,
612
613    pub color: Option<String>,
614
615    pub align: Option<TextAlign>,
616
617    pub font: Option<String>,
618
619    pub wrap: Option<String>, // "word" = word wrap, null/none = no wrap
620
621    pub line_spacing: Option<u32>,
622
623    /// Integer text-scale factor for the proportional path — every glyph pixel
624    /// becomes a `scale × scale` block. `None`/`1` = normal size. Powers big
625    /// title/heading text (e.g. a title-screen logo). Ignored on the tile path.
626    pub scale: Option<u32>,
627}
628
629#[derive(Debug, Clone)]
630pub enum TextAlign {
631    Left,
632
633    Center,
634
635    Right,
636}
637
638impl<'de> Deserialize<'de> for TextAlign {
639    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
640        let s = String::deserialize(d)?;
641        match s.to_lowercase().as_str() {
642            "left" => Ok(TextAlign::Left),
643            "center" => Ok(TextAlign::Center),
644            "right" => Ok(TextAlign::Right),
645            _ => Err(de::Error::custom(format!("unknown TextAlign: {s}"))),
646        }
647    }
648}
649
650// ============================================================================
651// TileParams
652// ============================================================================
653
654#[derive(Debug, Clone, Deserialize)]
655#[serde(deny_unknown_fields)]
656#[non_exhaustive]
657pub struct TileParams {
658    pub tile_id: serde_json::Value,
659
660    #[serde(default)]
661    pub flip_x: bool,
662
663    #[serde(default)]
664    pub flip_y: bool,
665
666    pub palette: Option<String>,
667
668    pub repeat: Option<u32>,
669}
670
671// ============================================================================
672// DividerParams
673// ============================================================================
674
675#[derive(Debug, Clone, Deserialize)]
676#[serde(deny_unknown_fields)]
677#[non_exhaustive]
678pub struct DividerParams {
679    pub tiles: Vec<u16>,
680
681    pub repeat: u32,
682
683    #[serde(default)]
684    pub orientation: Direction,
685}
686
687// ============================================================================
688// ImageParams
689// ============================================================================
690
691#[derive(Debug, Clone, Deserialize)]
692#[serde(deny_unknown_fields)]
693#[non_exhaustive]
694pub struct ImageParams {
695    /// The image key/template resolved via [`DataContext::resolve`] and looked up
696    /// in [`RenderContext::images`]. The `.gui` DSL emits this as `src` (shared
697    /// with the scene-`ui` schema), so accept both spellings.
698    #[serde(alias = "src")]
699    pub source: String,
700
701    #[serde(default)]
702    pub flip_x: bool,
703
704    #[serde(default)]
705    pub flip_y: bool,
706
707    pub palette: Option<String>,
708}
709
710// ============================================================================
711// ListParams
712// ============================================================================
713
714#[derive(Debug, Clone, Deserialize)]
715#[serde(deny_unknown_fields)]
716#[non_exhaustive]
717pub struct ListParams {
718    pub items: String,
719
720    pub item_template: ItemTemplate,
721
722    #[serde(default)]
723    pub cursor: ListCursor,
724
725    /// Index of the highlighted item (defaults to the first item). The
726    /// cursor glyph is drawn next to this row.
727    #[serde(default)]
728    pub selected: Option<Coord>,
729
730    pub max_visible: Option<usize>,
731
732    pub footer: Option<String>,
733}
734
735// ============================================================================
736// ListCursor — cursor style for list / flex_list elements
737// ============================================================================
738
739/// Cursor configuration for a scrollable list. Authored either as a bare
740/// tile id number (`"cursor": 223`) or as an object
741/// (`"cursor": {"tile": 223, "position": "left"}`).
742#[derive(Debug, Clone, Default)]
743#[non_exhaustive]
744pub struct ListCursor {
745    /// Tile id used to draw the cursor glyph (default ▶).
746    pub tile: Option<u32>,
747    /// Position hint (e.g. `"left"`) — currently informational.
748    pub position: Option<String>,
749}
750
751impl<'de> Deserialize<'de> for ListCursor {
752    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
753        #[derive(Deserialize)]
754        struct CursorObj {
755            #[serde(default)]
756            tile: Option<u32>,
757            #[serde(default)]
758            position: Option<String>,
759        }
760
761        struct CursorVisitor;
762
763        impl<'de> serde::de::Visitor<'de> for CursorVisitor {
764            type Value = ListCursor;
765
766            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
767                f.write_str("a tile id number or a {tile, position} object")
768            }
769
770            fn visit_u64<E: de::Error>(self, v: u64) -> Result<ListCursor, E> {
771                Ok(ListCursor { tile: Some(v as u32), position: None })
772            }
773
774            fn visit_i64<E: de::Error>(self, v: i64) -> Result<ListCursor, E> {
775                Ok(ListCursor { tile: Some(v.max(0) as u32), position: None })
776            }
777
778            fn visit_map<A: serde::de::MapAccess<'de>>(
779                self,
780                map: A,
781            ) -> Result<ListCursor, A::Error> {
782                let obj =
783                    CursorObj::deserialize(de::value::MapAccessDeserializer::new(map))?;
784                Ok(ListCursor { tile: obj.tile, position: obj.position })
785            }
786
787            fn visit_unit<E: de::Error>(self) -> Result<ListCursor, E> {
788                Ok(ListCursor::default())
789            }
790        }
791
792        d.deserialize_any(CursorVisitor)
793    }
794}
795
796// ============================================================================
797// FlexListParams
798// ============================================================================
799
800#[derive(Debug, Clone, Deserialize)]
801#[serde(deny_unknown_fields)]
802#[non_exhaustive]
803pub struct FlexListParams {
804    pub items: String,
805
806    pub item_layout: Vec<ColumnDef>,
807
808    pub padding: EdgeInsets,
809
810    pub gap: u32,
811
812    #[serde(default)]
813    pub cursor: ListCursor,
814
815    /// Index of the highlighted row (defaults to the first row).
816    #[serde(default)]
817    pub selected: Option<Coord>,
818}
819
820// ============================================================================
821// GroupParams
822// ============================================================================
823
824#[derive(Debug, Clone, Deserialize)]
825#[serde(deny_unknown_fields)]
826#[non_exhaustive]
827pub struct GroupParams {
828    pub layout: LayoutConfig,
829
830    #[serde(default)]
831    pub clip: bool,
832
833    pub children: Vec<LayoutElement>,
834}
835
836#[derive(Debug, Clone, Deserialize)]
837#[non_exhaustive]
838pub struct LayoutConfig {
839    #[serde(default)]
840    pub direction: Option<Direction>,
841
842    #[serde(default)]
843    pub gap: u32,
844
845    #[serde(default)]
846    pub padding: EdgeInsets,
847}
848
849#[derive(Debug, Clone, Deserialize, Default)]
850pub enum Direction {
851    #[default]
852    Horizontal,
853
854    Vertical,
855}
856
857// ============================================================================
858// ItemTemplate / ColumnDef
859// ============================================================================
860
861#[derive(Debug, Clone, Deserialize)]
862#[non_exhaustive]
863pub struct ItemTemplate {
864    pub height: u32,
865
866    pub gap: u32,
867}
868
869#[derive(Debug, Clone, Deserialize)]
870#[non_exhaustive]
871pub struct ColumnDef {
872    pub field: String,
873
874    pub width: u32,
875
876    pub align: Option<TextAlign>,
877
878    pub prefix: Option<String>,
879}
880
881// ============================================================================
882// EdgeInsets
883// ============================================================================
884
885#[derive(Debug, Clone, Deserialize)]
886#[non_exhaustive]
887pub struct EdgeInsets {
888    #[serde(default)]
889    pub top: u32,
890
891    #[serde(default)]
892    pub bottom: u32,
893
894    #[serde(default)]
895    pub left: u32,
896
897    #[serde(default)]
898    pub right: u32,
899}
900
901impl Default for EdgeInsets {
902    fn default() -> Self {
903        Self {
904            top: 0,
905            bottom: 0,
906            left: 0,
907            right: 0,
908        }
909    }
910}
911
912// ============================================================================
913// RenderError
914// ============================================================================
915
916#[derive(Debug, Clone, Deserialize, Error)]
917pub enum RenderError {
918    #[error("invalid layout")]
919    InvalidLayout,
920
921    #[error("unknown element type")]
922    UnknownElement,
923
924    #[error("missing variable")]
925    MissingVariable,
926
927    #[error("render failed")]
928    RenderFailed,
929}
930
931// ============================================================================
932// DataValue
933// ============================================================================
934
935#[derive(Debug, Clone, PartialEq, Deserialize)]
936pub enum DataValue {
937    Str(String),
938
939    Int(i64),
940
941    Float(f64),
942
943    Bool(bool),
944
945    List(Vec<DataValue>),
946
947    TileId(u16),
948}
949
950// ============================================================================
951// From conversions for DataValue
952// ============================================================================
953
954impl From<String> for DataValue {
955    fn from(s: String) -> Self {
956        DataValue::Str(s)
957    }
958}
959
960impl From<&str> for DataValue {
961    fn from(s: &str) -> Self {
962        DataValue::Str(s.to_string())
963    }
964}
965
966impl From<i64> for DataValue {
967    fn from(n: i64) -> Self {
968        DataValue::Int(n)
969    }
970}
971
972impl From<u16> for DataValue {
973    fn from(n: u16) -> Self {
974        DataValue::TileId(n)
975    }
976}
977
978impl From<bool> for DataValue {
979    fn from(b: bool) -> Self {
980        DataValue::Bool(b)
981    }
982}
983
984impl From<Vec<DataValue>> for DataValue {
985    fn from(v: Vec<DataValue>) -> Self {
986        DataValue::List(v)
987    }
988}
989
990// ============================================================================
991// RenderContext
992// ============================================================================
993
994#[derive(Debug, Clone)]
995#[non_exhaustive]
996pub struct RenderContext<'a> {
997    pub screen: &'a str,
998
999    pub theme: &'a Theme,
1000
1001    pub fonts: &'a FontRegistry,
1002
1003    pub tilesets: &'a TilesetRegistry,
1004
1005    /// Full-colour images the `image` element blits, keyed by its resolved
1006    /// `source`. Defaults to a shared empty registry; attach with
1007    /// [`RenderContext::with_images`].
1008    pub images: &'a ImageRegistry,
1009}
1010
1011impl<'a> RenderContext<'a> {
1012    pub fn new(
1013        screen: &'a str,
1014        theme: &'a Theme,
1015        fonts: &'a FontRegistry,
1016        tilesets: &'a TilesetRegistry,
1017    ) -> Self {
1018        Self {
1019            screen,
1020            theme,
1021            fonts,
1022            tilesets,
1023            images: empty_image_registry(),
1024        }
1025    }
1026
1027    /// Attach an image registry the `image` element blits from.
1028    pub fn with_images(mut self, images: &'a ImageRegistry) -> Self {
1029        self.images = images;
1030        self
1031    }
1032}
1033
1034// ============================================================================
1035// DataContext
1036// ============================================================================
1037
1038#[derive(Debug, Clone)]
1039#[non_exhaustive]
1040pub struct DataContext {
1041    pub(crate) values: HashMap<String, DataValue>,
1042}