1use crate::layout_engine::types::{RenderError, ScreenLayout};
8use std::path::Path;
9
10pub fn load_layout(name: &str) -> Result<ScreenLayout, RenderError> {
23 let candidate_paths = [format!("data/ui_layouts/{}.json", name)];
24
25 for path in &candidate_paths {
26 if Path::new(path).exists() {
27 let json = std::fs::read_to_string(path).map_err(|e| {
28 log::warn!("Failed to read layout file '{}': {}", path, e);
29 RenderError::InvalidLayout
30 })?;
31 return parse_layout(&json);
32 }
33 }
34
35 log::warn!(
36 "Layout file not found for '{}' (tried: {:?})",
37 name,
38 candidate_paths
39 );
40 Err(RenderError::InvalidLayout)
41}
42
43pub fn parse_layout(json: &str) -> Result<ScreenLayout, RenderError> {
54 let layout: ScreenLayout = serde_json::from_str(json).map_err(|e| {
55 log::warn!("Failed to parse layout JSON: {}", e);
56 RenderError::InvalidLayout
57 })?;
58
59 validate_layout(&layout);
60 Ok(layout)
61}
62
63fn validate_layout(layout: &ScreenLayout) {
68 for element in &layout.elements {
69 let id = if element.id.is_empty() {
70 format!("<type:{}>", element.element_type)
71 } else {
72 element.id.clone()
73 };
74
75 if element.rect.tw == Some(0) {
77 log::warn!(
78 "Element '{}' has tw=0 — nothing will be drawn horizontally",
79 id
80 );
81 }
82 if element.rect.th == Some(0) {
83 log::warn!(
84 "Element '{}' has th=0 — nothing will be drawn vertically",
85 id
86 );
87 }
88
89 match element.element_type.as_str() {
92 "group" | "border" | "text" | "tile" | "divider" | "image"
93 | "list" | "flex_list" => {}
94 t if t.starts_with("custom:") => {}
95 _ => {
96 log::warn!(
97 "Unknown element type '{}' in element '{}' — will be skipped at render time",
98 element.element_type,
99 id,
100 );
101 }
102 }
103 }
104}
105
106#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
115 fn test_parse_basic_layout() {
116 let json = r##"{
117 "schema_version": 1,
118 "screen": "test",
119 "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
120 "elements": [
121 {
122 "type": "text",
123 "rect": { "tx": 0, "ty": 0, "tw": 10, "th": 2 },
124 "value": "Hello World",
125 "color": "black"
126 },
127 {
128 "type": "border",
129 "rect": { "tx": 0, "ty": 0, "tw": 10, "th": 5 },
130 "style": "Single"
131 },
132 {
133 "type": "tile",
134 "rect": { "tx": 5, "ty": 5, "tw": 1, "th": 1 },
135 "tile_id": 42
136 }
137 ]
138 }"##;
139
140 let layout = parse_layout(json).expect("should parse");
141 assert_eq!(layout.schema_version, 1);
142 assert_eq!(layout.screen, "test");
143 assert_eq!(layout.elements.len(), 3);
144
145 let e0 = &layout.elements[0];
147 assert_eq!(e0.element_type, "text");
148 assert_eq!(e0.rect.tx.as_literal(), Some(0));
149 assert_eq!(e0.rect.ty.as_literal(), Some(0));
150
151 let e1 = &layout.elements[1];
153 assert_eq!(e1.element_type, "border");
154 assert_eq!(e1.rect.tw, Some(10));
155
156 let e2 = &layout.elements[2];
158 assert_eq!(e2.element_type, "tile");
159 assert_eq!(e2.rect.tx.as_literal(), Some(5));
160 }
161
162 #[test]
165 fn test_parse_empty_elements() {
166 let json = r##"{
167 "schema_version": 1,
168 "screen": "empty",
169 "theme": { "bg_color": "#000000", "default_font": "default" },
170 "elements": []
171 }"##;
172
173 let layout = parse_layout(json).expect("should parse even with zero elements");
174 assert_eq!(layout.screen, "empty");
175 assert!(layout.elements.is_empty());
176 }
177
178 #[test]
181 fn test_parse_invalid_json() {
182 let result = parse_layout(r##"{"schema_version": 1, "screen": "bad""##);
184 assert!(result.is_err(), "invalid JSON should return error");
185 match result {
186 Err(RenderError::InvalidLayout) => {} other => panic!("expected InvalidLayout, got {:?}", other),
188 }
189 }
190
191 #[test]
192 fn test_parse_missing_required_field() {
193 let json = r##"{
195 "schema_version": 1,
196 "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
197 "elements": []
198 }"##;
199 let result = parse_layout(json);
200 assert!(result.is_err(), "missing required field should error");
201 }
202
203 #[test]
204 fn test_parse_wrong_types() {
205 let json = r##"{
207 "schema_version": 1,
208 "screen": "test",
209 "elements": { "not": "an array" }
210 }"##;
211 let result = parse_layout(json);
212 assert!(result.is_err(), "wrong type should error");
213 }
214
215 #[test]
218 fn test_parse_unknown_type() {
219 let json = r##"{
222 "schema_version": 1,
223 "screen": "test",
224 "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
225 "elements": [
226 {
227 "type": "foobar_unknown",
228 "rect": { "tx": 0, "ty": 0, "tw": 5, "th": 5 },
229 "some_param": "hello"
230 }
231 ]
232 }"##;
233
234 let layout = parse_layout(json).expect("should parse unknown types");
235 assert_eq!(layout.elements.len(), 1);
236 assert_eq!(layout.elements[0].element_type, "foobar_unknown");
237 }
239
240 #[test]
243 fn test_parse_dex_layout() {
244 let json = r##"{
245 "schema_version": 1,
246 "screen": "dex",
247 "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
248 "elements": [
249 {
250 "id": "dex_border",
251 "type": "border",
252 "rect": { "tx": 0, "ty": 0, "tw": 20, "th": 18 },
253 "style": "Single"
254 },
255 {
256 "id": "dex_title",
257 "type": "text",
258 "rect": { "tx": 1, "ty": 1, "tw": 18, "th": 2 },
259 "value": "DEX",
260 "color": "black",
261 "align": "Center"
262 },
263 {
264 "id": "separator",
265 "type": "divider",
266 "rect": { "tx": 1, "ty": 2, "tw": 18, "th": 1 },
267 "tiles": [122],
268 "repeat": 18
269 },
270 {
271 "id": "dex_image",
272 "type": "image",
273 "rect": { "tx": 1, "ty": 3, "tw": 7, "th": 7 },
274 "source": "{monster_sprite}"
275 },
276 {
277 "id": "dex_num",
278 "type": "text",
279 "rect": { "tx": 9, "ty": 3, "tw": 10, "th": 1 },
280 "value": "\u2116\u2022{dex_num:03}",
281 "color": "black"
282 },
283 {
284 "id": "dex_name",
285 "type": "text",
286 "rect": { "tx": 9, "ty": 4, "tw": 10, "th": 1 },
287 "value": "{name}",
288 "color": "black"
289 },
290 {
291 "id": "dex_height",
292 "type": "text",
293 "rect": { "tx": 9, "ty": 5, "tw": 10, "th": 1 },
294 "value": "HT {feet}\u2032{inches:02}\u2033",
295 "color": "black"
296 },
297 {
298 "id": "dex_weight",
299 "type": "text",
300 "rect": { "tx": 9, "ty": 6, "tw": 10, "th": 1 },
301 "value": "WT {weight%10}.{weight%10} lb",
302 "color": "black"
303 },
304 {
305 "id": "dex_desc",
306 "type": "text",
307 "rect": { "tx": 1, "ty": 11, "tw": 18, "th": 6 },
308 "value": "{description}",
309 "color": "black",
310 "wrap": true
311 }
312 ]
313 }"##;
314
315 let layout = parse_layout(json).expect("dex layout should parse");
316 assert_eq!(layout.screen, "dex");
317 assert_eq!(layout.elements.len(), 9);
318
319 let ids: Vec<&str> = layout.elements.iter().map(|e| e.id.as_str()).collect();
321 assert!(ids.contains(&"dex_border"));
322 assert!(ids.contains(&"dex_title"));
323 assert!(ids.contains(&"dex_image"));
324 assert!(ids.contains(&"dex_name"));
325 assert!(ids.contains(&"dex_desc"));
326 }
327
328 #[test]
331 fn test_load_layout_not_found() {
332 let result = load_layout("__nonexistent_layout_xyzzy__");
333 assert!(result.is_err(), "missing file should error");
334 match result {
335 Err(RenderError::InvalidLayout) => {} other => panic!("expected InvalidLayout, got {:?}", other),
337 }
338 }
339
340 #[test]
343 fn test_parse_minimal_layout() {
344 let json = r##"{
346 "schema_version": 1,
347 "screen": "minimal",
348 "elements": []
349 }"##;
350
351 let layout = parse_layout(json).expect("minimal should parse");
352 assert_eq!(layout.screen, "minimal");
353 assert_eq!(layout.elements.len(), 0);
354 }
355
356 #[test]
359 fn test_parse_group_layout() {
360 let json = r##"{
361 "schema_version": 1,
362 "screen": "grouped",
363 "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
364 "elements": [
365 {
366 "id": "container",
367 "type": "group",
368 "rect": { "tx": 2, "ty": 2, "tw": 16, "th": 14 },
369 "layout": {
370 "direction": "Vertical",
371 "gap": 2,
372 "padding": { "top": 1, "bottom": 1, "left": 1, "right": 1 }
373 },
374 "clip": true,
375 "children": [
376 {
377 "type": "text",
378 "rect": { "tx": 0, "ty": 0, "tw": 14, "th": 2 },
379 "value": "ITEM",
380 "color": "black"
381 },
382 {
383 "type": "text",
384 "rect": { "tx": 0, "ty": 2, "tw": 14, "th": 2 },
385 "value": "DESCRIPTION",
386 "color": "darkgray",
387 "wrap": true
388 }
389 ]
390 }
391 ]
392 }"##;
393
394 let layout = parse_layout(json).expect("group layout should parse");
395 assert_eq!(layout.elements.len(), 1);
396 assert_eq!(layout.elements[0].element_type, "group");
397 assert_eq!(layout.elements[0].id, "container");
398
399 if let crate::layout_engine::types::ElementParams::Group(ref gp) =
401 layout.elements[0].params
402 {
403 assert_eq!(gp.children.len(), 2);
404 assert!(gp.clip);
405 assert!(matches!(
406 gp.layout.direction,
407 Some(crate::layout_engine::types::Direction::Vertical)
408 ));
409 assert_eq!(gp.layout.gap, 2);
410 } else {
411 panic!("expected Group params");
412 }
413 }
414
415 #[test]
418 fn test_parse_custom_element() {
419 let json = r##"{
420 "schema_version": 1,
421 "screen": "custom",
422 "elements": [
423 {
424 "type": "custom:monster_sprite",
425 "rect": { "tx": 0, "ty": 0, "tw": 7, "th": 7 },
426 "sprite_id": 25,
427 "palette": "default"
428 }
429 ]
430 }"##;
431
432 let layout = parse_layout(json).expect("custom element should parse");
433 assert_eq!(layout.elements.len(), 1);
434 assert_eq!(layout.elements[0].element_type, "custom:monster_sprite");
435 }
436
437 #[test]
440 fn test_parse_z_index_default() {
441 let json = r##"{
442 "schema_version": 1,
443 "screen": "z_test",
444 "elements": [
445 { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "bg" },
446 { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "fg", "z_index": 10 }
447 ]
448 }"##;
449
450 let layout = parse_layout(json).expect("should parse");
451 assert_eq!(layout.elements[0].z_index, 0); assert_eq!(layout.elements[1].z_index, 10);
453 }
454
455 #[test]
458 fn test_parse_visible_default_and_explicit() {
459 let json = r##"{
460 "schema_version": 1,
461 "screen": "vis_test",
462 "elements": [
463 { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "visible" },
464 { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "hidden", "visible": false }
465 ]
466 }"##;
467
468 let layout = parse_layout(json).expect("should parse");
469 let ctx = crate::layout_engine::types::DataContext::new();
470 assert!(layout.elements[0].visible.eval(&ctx)); assert!(!layout.elements[1].visible.eval(&ctx)); }
473
474 #[test]
477 fn test_parse_flex_list_layout() {
478 let json = r##"{
479 "schema_version": 1,
480 "screen": "mart",
481 "elements": [
482 {
483 "type": "flex_list",
484 "rect": { "tx": 1, "ty": 3, "tw": 18, "th": 12 },
485 "items": "{inventory}",
486 "cursor": 0,
487 "gap": 0,
488 "padding": { "top": 0, "bottom": 0, "left": 1, "right": 0 },
489 "item_layout": [
490 { "field": "name", "width": 12, "align": "Left" },
491 { "field": "price", "width": 5, "align": "Right", "prefix": "$" }
492 ]
493 }
494 ]
495 }"##;
496
497 let layout = parse_layout(json).expect("flex_list should parse");
498 assert_eq!(layout.elements.len(), 1);
499 assert_eq!(layout.elements[0].element_type, "flex_list");
500 }
501}