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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SchemaViolation {
82 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
93pub trait CustomElement: Debug + Send + Sync {
101 fn element_type(&self) -> &'static str;
107
108 fn schema(&self) -> ComponentSchema {
117 ComponentSchema::default()
118 }
119
120 fn render(
131 &self,
132 element: &LayoutElement,
133 ctx: &DataContext,
134 render_ctx: &RenderContext,
135 painter: &mut dyn Painter,
136 ) -> Result<(), RenderError>;
137}
138
139pub struct ElementRegistry {
157 elements: HashMap<String, Box<dyn CustomElement>>,
158}
159
160impl ElementRegistry {
161 pub fn new() -> Self {
163 Self {
164 elements: HashMap::new(),
165 }
166 }
167
168 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 pub fn get(&self, type_name: &str) -> Option<&dyn CustomElement> {
182 self.elements.get(type_name).map(|e| e.as_ref())
183 }
184
185 pub fn contains(&self, type_name: &str) -> bool {
187 self.elements.contains_key(type_name)
188 }
189
190 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 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 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 #[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 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}