Skip to main content

dotzuki_renderer/layout_engine/
registry.rs

1use std::collections::HashMap;
2use std::fmt::Debug;
3
4use crate::layout_engine::types::{
5    DataContext, ElementParams, LayoutElement, RenderContext, RenderError, ScreenLayout,
6};
7use dotzuki_engine::render::Painter;
8
9/// Value kind a custom-element prop accepts, mirroring the DSL's
10/// `component` declaration kinds.
11///
12/// `Expr` admits anything a data binding can carry in the compiled JSON — a
13/// number or a (possibly `"{var}"`-templated) string.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PropType {
16    Int,
17    String,
18    Bool,
19    Color,
20    Expr,
21}
22
23impl PropType {
24    fn matches(self, value: &serde_json::Value) -> bool {
25        match self {
26            PropType::Int => value.is_i64() || value.is_u64(),
27            PropType::String | PropType::Color => value.is_string(),
28            PropType::Bool => value.is_boolean(),
29            PropType::Expr => value.is_number() || value.is_string(),
30        }
31    }
32
33    fn name(self) -> &'static str {
34        match self {
35            PropType::Int => "int",
36            PropType::String => "string",
37            PropType::Bool => "bool",
38            PropType::Color => "color",
39            PropType::Expr => "expr",
40        }
41    }
42}
43
44/// One prop of a [`ComponentSchema`].
45#[derive(Debug, Clone)]
46pub struct PropSpec {
47    pub name: &'static str,
48    pub ty: PropType,
49    pub required: bool,
50}
51
52impl PropSpec {
53    pub const fn required(name: &'static str, ty: PropType) -> Self {
54        Self { name, ty, required: true }
55    }
56
57    pub const fn optional(name: &'static str, ty: PropType) -> Self {
58        Self { name, ty, required: false }
59    }
60}
61
62/// The prop schema a [`CustomElement`] expects — the runtime counterpart of
63/// the DSL's `component` declaration.
64///
65/// Layouts are checked against it once at load time
66/// ([`ElementRegistry::validate_layout`]), so authoring mistakes surface as
67/// one clear error instead of a silently blank element every frame.
68#[derive(Debug, Clone, Default)]
69pub struct ComponentSchema {
70    pub props: Vec<PropSpec>,
71}
72
73impl ComponentSchema {
74    pub fn new(props: Vec<PropSpec>) -> Self {
75        Self { props }
76    }
77}
78
79/// A schema violation found by [`ElementRegistry::validate_layout`].
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SchemaViolation {
82    /// Element id, or `<type:...>` when the element has no id.
83    pub element: String,
84    pub message: String,
85}
86
87impl std::fmt::Display for SchemaViolation {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "{}: {}", self.element, self.message)
90    }
91}
92
93/// A custom layout element that can be registered from any game or plugin.
94///
95/// Implement this trait to provide rendering logic for a new element type
96/// (e.g. `custom:monster_sprite`, `custom:hp_bar`). Once registered via
97/// [`ElementRegistry::register`], the layout engine will dispatch elements
98/// of that type to the implementation's [`render`](CustomElement::render)
99/// method.
100pub trait CustomElement: Debug + Send + Sync {
101    /// Returns the unique element type identifier.
102    ///
103    /// This string is matched against the `type` field in a
104    /// [`LayoutElement`] at dispatch time. Convention: use a colon-prefixed
105    /// namespace, e.g. `"custom:monster_sprite"` or `"custom:hp_bar"`.
106    fn element_type(&self) -> &'static str;
107
108    /// The prop schema this element expects.
109    ///
110    /// Used by [`ElementRegistry::validate_layout`] to check layouts at load
111    /// time. The default (empty) schema accepts any props — override it to
112    /// get required-prop and type checking. Keep it in sync with the
113    /// `component` declaration in the game's `.gui` prelude; a unit test
114    /// deserialising a schema-shaped params object through the element's own
115    /// param struct is the cheapest drift guard.
116    fn schema(&self) -> ComponentSchema {
117        ComponentSchema::default()
118    }
119
120    /// Render this custom element into the framebuffer.
121    ///
122    /// # Arguments
123    ///
124    /// * `element` — The layout element definition (contains id, rect,
125    ///   element-specific params, etc.).
126    /// * `ctx` — Per-frame mutable data context for template variables.
127    /// * `render_ctx` — Shared immutable rendering state (screen, theme,
128    ///   fonts, tilesets).
129    /// * `painter` — The painter used to draw into the framebuffer.
130    fn render(
131        &self,
132        element: &LayoutElement,
133        ctx: &DataContext,
134        render_ctx: &RenderContext,
135        painter: &mut dyn Painter,
136    ) -> Result<(), RenderError>;
137}
138
139/// A global registry of custom element types.
140///
141/// Games or plugins register their custom element implementations here
142/// before layout rendering begins. The layout engine looks up elements
143/// by their `type` string at render time and dispatches to the
144/// corresponding [`CustomElement`] if found.
145///
146/// # Example
147///
148/// ```ignore
149/// let mut registry = ElementRegistry::new();
150/// registry.register(Box::new(MyMonsterSpriteElement));
151///
152/// if let Some(element) = registry.get("custom:monster_sprite") {
153///     element.render(&layout_elem, &ctx, &render_ctx, &mut painter)?;
154/// }
155/// ```
156pub struct ElementRegistry {
157    elements: HashMap<String, Box<dyn CustomElement>>,
158}
159
160impl ElementRegistry {
161    /// Create a new empty registry.
162    pub fn new() -> Self {
163        Self {
164            elements: HashMap::new(),
165        }
166    }
167
168    /// Register a custom element implementation.
169    ///
170    /// The element's [`element_type`](CustomElement::element_type) is used
171    /// as the lookup key. If an element with the same type is already
172    /// registered, it is replaced.
173    pub fn register(&mut self, element: Box<dyn CustomElement>) {
174        let type_name = element.element_type().to_string();
175        self.elements.insert(type_name, element);
176    }
177
178    /// Look up a custom element by its type name.
179    ///
180    /// Returns `None` if no element with the given type has been registered.
181    pub fn get(&self, type_name: &str) -> Option<&dyn CustomElement> {
182        self.elements.get(type_name).map(|e| e.as_ref())
183    }
184
185    /// Check whether a custom element type has been registered.
186    pub fn contains(&self, type_name: &str) -> bool {
187        self.elements.contains_key(type_name)
188    }
189
190    /// Validate every `custom:*` element of `layout` against the registered
191    /// schemas. Call once when a layout is loaded — render-time dispatch does
192    /// no checking.
193    ///
194    /// Checks per element (recursing into `border`/`group` children):
195    /// - the element type is registered;
196    /// - every `required` schema prop is present;
197    /// - present schema props match their declared [`PropType`].
198    ///
199    /// Props outside the schema are NOT flagged: the DSL compiler already
200    /// rejects undeclared props at build time, and standard layout props
201    /// (`align`, `padding`, …) may legitimately accompany any element.
202    pub fn validate_layout(&self, layout: &ScreenLayout) -> Result<(), Vec<SchemaViolation>> {
203        let mut violations = Vec::new();
204        for element in &layout.elements {
205            self.validate_element(element, &mut violations);
206        }
207        if violations.is_empty() {
208            Ok(())
209        } else {
210            Err(violations)
211        }
212    }
213
214    fn validate_element(&self, element: &LayoutElement, out: &mut Vec<SchemaViolation>) {
215        // Recurse into containers first.
216        match &element.params {
217            ElementParams::Border(b) => {
218                for child in &b.children {
219                    self.validate_element(child, out);
220                }
221            }
222            ElementParams::Group(g) => {
223                for child in &g.children {
224                    self.validate_element(child, out);
225                }
226            }
227            _ => {}
228        }
229
230        if !element.element_type.starts_with("custom:") {
231            return;
232        }
233        let label = if element.id.is_empty() {
234            format!("<type:{}>", element.element_type)
235        } else {
236            element.id.clone()
237        };
238
239        let Some(custom) = self.get(&element.element_type) else {
240            out.push(SchemaViolation {
241                element: label,
242                message: format!(
243                    "element type '{}' is not registered (registered: {})",
244                    element.element_type,
245                    if self.elements.is_empty() {
246                        "none".to_string()
247                    } else {
248                        self.elements.keys().cloned().collect::<Vec<_>>().join(", ")
249                    }
250                ),
251            });
252            return;
253        };
254
255        let schema = custom.schema();
256        if schema.props.is_empty() {
257            return;
258        }
259        let empty = serde_json::Map::new();
260        let params = match &element.params {
261            ElementParams::Custom(serde_json::Value::Object(map)) => map,
262            _ => &empty,
263        };
264        for spec in &schema.props {
265            match params.get(spec.name) {
266                Some(value) => {
267                    if !spec.ty.matches(value) {
268                        out.push(SchemaViolation {
269                            element: label.clone(),
270                            message: format!(
271                                "prop '{}' expects a {} value, got {}",
272                                spec.name,
273                                spec.ty.name(),
274                                value
275                            ),
276                        });
277                    }
278                }
279                None if spec.required => {
280                    out.push(SchemaViolation {
281                        element: label.clone(),
282                        message: format!(
283                            "missing required prop '{}' ({})",
284                            spec.name,
285                            spec.ty.name()
286                        ),
287                    });
288                }
289                None => {}
290            }
291        }
292    }
293}
294
295impl Default for ElementRegistry {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_registry_empty() {
307        let registry = ElementRegistry::new();
308        assert!(!registry.contains("custom:test"));
309        assert!(registry.get("custom:test").is_none());
310    }
311
312    #[test]
313    fn test_register_and_get() {
314        #[derive(Debug)]
315        struct TestElement;
316
317        impl CustomElement for TestElement {
318            fn element_type(&self) -> &'static str {
319                "custom:test"
320            }
321
322            fn render(
323                &self,
324                _element: &LayoutElement,
325                _ctx: &DataContext,
326                _render_ctx: &RenderContext,
327                _painter: &mut dyn Painter,
328            ) -> Result<(), RenderError> {
329                Ok(())
330            }
331        }
332
333        let mut registry = ElementRegistry::new();
334        registry.register(Box::new(TestElement));
335
336        assert!(registry.contains("custom:test"));
337        assert!(registry.get("custom:test").is_some());
338    }
339
340    #[test]
341    fn test_register_replaces_existing() {
342        #[derive(Debug)]
343        struct FirstElement;
344
345        impl CustomElement for FirstElement {
346            fn element_type(&self) -> &'static str {
347                "custom:replace_me"
348            }
349
350            fn render(
351                &self,
352                _element: &LayoutElement,
353                _ctx: &DataContext,
354                _render_ctx: &RenderContext,
355                _painter: &mut dyn Painter,
356            ) -> Result<(), RenderError> {
357                Ok(())
358            }
359        }
360
361        #[derive(Debug)]
362        struct SecondElement;
363
364        impl CustomElement for SecondElement {
365            fn element_type(&self) -> &'static str {
366                "custom:replace_me"
367            }
368
369            fn render(
370                &self,
371                _element: &LayoutElement,
372                _ctx: &DataContext,
373                _render_ctx: &RenderContext,
374                _painter: &mut dyn Painter,
375            ) -> Result<(), RenderError> {
376                Ok(())
377            }
378        }
379
380        let mut registry = ElementRegistry::new();
381        registry.register(Box::new(FirstElement));
382        registry.register(Box::new(SecondElement));
383
384        // The second registration should replace the first
385        assert!(registry.contains("custom:replace_me"));
386    }
387
388    #[test]
389    fn test_multiple_custom_elements() {
390        #[derive(Debug)]
391        struct SpriteElement;
392        impl CustomElement for SpriteElement {
393            fn element_type(&self) -> &'static str {
394                "custom:sprite"
395            }
396            fn render(
397                &self,
398                _element: &LayoutElement,
399                _ctx: &DataContext,
400                _render_ctx: &RenderContext,
401                _painter: &mut dyn Painter,
402            ) -> Result<(), RenderError> {
403                Ok(())
404            }
405        }
406
407        #[derive(Debug)]
408        struct HpBarElement;
409        impl CustomElement for HpBarElement {
410            fn element_type(&self) -> &'static str {
411                "custom:hp_bar"
412            }
413            fn render(
414                &self,
415                _element: &LayoutElement,
416                _ctx: &DataContext,
417                _render_ctx: &RenderContext,
418                _painter: &mut dyn Painter,
419            ) -> Result<(), RenderError> {
420                Ok(())
421            }
422        }
423
424        let mut registry = ElementRegistry::new();
425        registry.register(Box::new(SpriteElement));
426        registry.register(Box::new(HpBarElement));
427
428        assert!(registry.contains("custom:sprite"));
429        assert!(registry.contains("custom:hp_bar"));
430        assert!(!registry.contains("custom:unknown"));
431    }
432
433    // ── validate_layout ───────────────────────────────────────────────
434
435    /// `custom:gauge` expecting `current`/`max` (expr, required) and
436    /// `segments` (int, optional).
437    #[derive(Debug)]
438    struct GaugeElement;
439
440    impl CustomElement for GaugeElement {
441        fn element_type(&self) -> &'static str {
442            "custom:gauge"
443        }
444
445        fn schema(&self) -> ComponentSchema {
446            ComponentSchema::new(vec![
447                PropSpec::required("current", PropType::Expr),
448                PropSpec::required("max", PropType::Expr),
449                PropSpec::optional("segments", PropType::Int),
450            ])
451        }
452
453        fn render(
454            &self,
455            _element: &LayoutElement,
456            _ctx: &DataContext,
457            _render_ctx: &RenderContext,
458            _painter: &mut dyn Painter,
459        ) -> Result<(), RenderError> {
460            Ok(())
461        }
462    }
463
464    fn gauge_registry() -> ElementRegistry {
465        let mut registry = ElementRegistry::new();
466        registry.register(Box::new(GaugeElement));
467        registry
468    }
469
470    fn layout_with(element_json: &str) -> ScreenLayout {
471        let json = format!(
472            r##"{{
473                "schema_version": 2,
474                "screen": "test",
475                "theme": {{ "bg_color": "#FFFFFF", "default_font": "default" }},
476                "elements": [{element_json}]
477            }}"##
478        );
479        crate::layout_engine::deserialize::parse_layout(&json).expect("layout should parse")
480    }
481
482    #[test]
483    fn validate_accepts_well_formed_custom_element() {
484        let layout = layout_with(
485            r#"{ "type": "custom:gauge", "rect": { "tx": 1, "ty": 2, "tw": 6, "th": 1 },
486                 "current": "{hp}", "max": 20, "segments": 4 }"#,
487        );
488        assert!(gauge_registry().validate_layout(&layout).is_ok());
489    }
490
491    #[test]
492    fn validate_flags_unregistered_custom_type() {
493        let layout = layout_with(
494            r#"{ "type": "custom:sparkline", "rect": { "tx": 0, "ty": 0, "tw": 1, "th": 1 } }"#,
495        );
496        let violations = gauge_registry().validate_layout(&layout).unwrap_err();
497        assert_eq!(violations.len(), 1);
498        assert!(violations[0].message.contains("not registered"));
499        assert!(violations[0].message.contains("custom:gauge"), "lists registered types");
500    }
501
502    #[test]
503    fn validate_flags_missing_required_prop() {
504        let layout = layout_with(
505            r#"{ "id": "hp", "type": "custom:gauge",
506                 "rect": { "tx": 0, "ty": 0, "tw": 6, "th": 1 }, "current": "{hp}" }"#,
507        );
508        let violations = gauge_registry().validate_layout(&layout).unwrap_err();
509        assert_eq!(violations.len(), 1);
510        assert_eq!(violations[0].element, "hp");
511        assert!(violations[0].message.contains("missing required prop 'max'"));
512    }
513
514    #[test]
515    fn validate_flags_prop_type_mismatch() {
516        let layout = layout_with(
517            r#"{ "type": "custom:gauge", "rect": { "tx": 0, "ty": 0, "tw": 6, "th": 1 },
518                 "current": "{hp}", "max": 20, "segments": "four" }"#,
519        );
520        let violations = gauge_registry().validate_layout(&layout).unwrap_err();
521        assert_eq!(violations.len(), 1);
522        assert!(violations[0].message.contains("'segments' expects a int value"));
523    }
524
525    #[test]
526    fn validate_recurses_into_border_children() {
527        let layout = layout_with(
528            r#"{ "type": "border", "rect": { "tx": 0, "ty": 0, "tw": 20, "th": 18 },
529                 "children": [
530                   { "type": "custom:gauge", "rect": { "tx": 1, "ty": 1, "tw": 6, "th": 1 } }
531                 ] }"#,
532        );
533        let violations = gauge_registry().validate_layout(&layout).unwrap_err();
534        assert_eq!(violations.len(), 2, "missing current AND max: {violations:?}");
535    }
536
537    #[test]
538    fn validate_ignores_extra_and_standard_props() {
539        // Undeclared props are the DSL compiler's concern; the runtime check
540        // stays lenient so standard layout props never false-positive.
541        let layout = layout_with(
542            r#"{ "type": "custom:gauge", "rect": { "tx": 0, "ty": 0, "tw": 6, "th": 1 },
543                 "current": "{hp}", "max": 20, "align": "center", "padding": [1] }"#,
544        );
545        assert!(gauge_registry().validate_layout(&layout).is_ok());
546    }
547}