use crate::layout_engine::types::{RenderError, ScreenLayout};
use std::path::Path;
pub fn load_layout(name: &str) -> Result<ScreenLayout, RenderError> {
let candidate_paths = [format!("data/ui_layouts/{}.json", name)];
for path in &candidate_paths {
if Path::new(path).exists() {
let json = std::fs::read_to_string(path).map_err(|e| {
log::warn!("Failed to read layout file '{}': {}", path, e);
RenderError::InvalidLayout
})?;
return parse_layout(&json);
}
}
log::warn!(
"Layout file not found for '{}' (tried: {:?})",
name,
candidate_paths
);
Err(RenderError::InvalidLayout)
}
pub fn parse_layout(json: &str) -> Result<ScreenLayout, RenderError> {
let layout: ScreenLayout = serde_json::from_str(json).map_err(|e| {
log::warn!("Failed to parse layout JSON: {}", e);
RenderError::InvalidLayout
})?;
validate_layout(&layout);
Ok(layout)
}
fn validate_layout(layout: &ScreenLayout) {
for element in &layout.elements {
let id = if element.id.is_empty() {
format!("<type:{}>", element.element_type)
} else {
element.id.clone()
};
if element.rect.tw == Some(0) {
log::warn!(
"Element '{}' has tw=0 — nothing will be drawn horizontally",
id
);
}
if element.rect.th == Some(0) {
log::warn!(
"Element '{}' has th=0 — nothing will be drawn vertically",
id
);
}
match element.element_type.as_str() {
"group" | "border" | "text" | "tile" | "divider" | "image"
| "list" | "flex_list" => {}
t if t.starts_with("custom:") => {}
_ => {
log::warn!(
"Unknown element type '{}' in element '{}' — will be skipped at render time",
element.element_type,
id,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_layout() {
let json = r##"{
"schema_version": 1,
"screen": "test",
"theme": { "bg_color": "#FFFFFF", "default_font": "default" },
"elements": [
{
"type": "text",
"rect": { "tx": 0, "ty": 0, "tw": 10, "th": 2 },
"value": "Hello World",
"color": "black"
},
{
"type": "border",
"rect": { "tx": 0, "ty": 0, "tw": 10, "th": 5 },
"style": "Single"
},
{
"type": "tile",
"rect": { "tx": 5, "ty": 5, "tw": 1, "th": 1 },
"tile_id": 42
}
]
}"##;
let layout = parse_layout(json).expect("should parse");
assert_eq!(layout.schema_version, 1);
assert_eq!(layout.screen, "test");
assert_eq!(layout.elements.len(), 3);
let e0 = &layout.elements[0];
assert_eq!(e0.element_type, "text");
assert_eq!(e0.rect.tx.as_literal(), Some(0));
assert_eq!(e0.rect.ty.as_literal(), Some(0));
let e1 = &layout.elements[1];
assert_eq!(e1.element_type, "border");
assert_eq!(e1.rect.tw, Some(10));
let e2 = &layout.elements[2];
assert_eq!(e2.element_type, "tile");
assert_eq!(e2.rect.tx.as_literal(), Some(5));
}
#[test]
fn test_parse_empty_elements() {
let json = r##"{
"schema_version": 1,
"screen": "empty",
"theme": { "bg_color": "#000000", "default_font": "default" },
"elements": []
}"##;
let layout = parse_layout(json).expect("should parse even with zero elements");
assert_eq!(layout.screen, "empty");
assert!(layout.elements.is_empty());
}
#[test]
fn test_parse_invalid_json() {
let result = parse_layout(r##"{"schema_version": 1, "screen": "bad""##);
assert!(result.is_err(), "invalid JSON should return error");
match result {
Err(RenderError::InvalidLayout) => {} other => panic!("expected InvalidLayout, got {:?}", other),
}
}
#[test]
fn test_parse_missing_required_field() {
let json = r##"{
"schema_version": 1,
"theme": { "bg_color": "#FFFFFF", "default_font": "default" },
"elements": []
}"##;
let result = parse_layout(json);
assert!(result.is_err(), "missing required field should error");
}
#[test]
fn test_parse_wrong_types() {
let json = r##"{
"schema_version": 1,
"screen": "test",
"elements": { "not": "an array" }
}"##;
let result = parse_layout(json);
assert!(result.is_err(), "wrong type should error");
}
#[test]
fn test_parse_unknown_type() {
let json = r##"{
"schema_version": 1,
"screen": "test",
"theme": { "bg_color": "#FFFFFF", "default_font": "default" },
"elements": [
{
"type": "foobar_unknown",
"rect": { "tx": 0, "ty": 0, "tw": 5, "th": 5 },
"some_param": "hello"
}
]
}"##;
let layout = parse_layout(json).expect("should parse unknown types");
assert_eq!(layout.elements.len(), 1);
assert_eq!(layout.elements[0].element_type, "foobar_unknown");
}
#[test]
fn test_parse_dex_layout() {
let json = r##"{
"schema_version": 1,
"screen": "dex",
"theme": { "bg_color": "#FFFFFF", "default_font": "default" },
"elements": [
{
"id": "dex_border",
"type": "border",
"rect": { "tx": 0, "ty": 0, "tw": 20, "th": 18 },
"style": "Single"
},
{
"id": "dex_title",
"type": "text",
"rect": { "tx": 1, "ty": 1, "tw": 18, "th": 2 },
"value": "DEX",
"color": "black",
"align": "Center"
},
{
"id": "separator",
"type": "divider",
"rect": { "tx": 1, "ty": 2, "tw": 18, "th": 1 },
"tiles": [122],
"repeat": 18
},
{
"id": "dex_image",
"type": "image",
"rect": { "tx": 1, "ty": 3, "tw": 7, "th": 7 },
"source": "{monster_sprite}"
},
{
"id": "dex_num",
"type": "text",
"rect": { "tx": 9, "ty": 3, "tw": 10, "th": 1 },
"value": "\u2116\u2022{dex_num:03}",
"color": "black"
},
{
"id": "dex_name",
"type": "text",
"rect": { "tx": 9, "ty": 4, "tw": 10, "th": 1 },
"value": "{name}",
"color": "black"
},
{
"id": "dex_height",
"type": "text",
"rect": { "tx": 9, "ty": 5, "tw": 10, "th": 1 },
"value": "HT {feet}\u2032{inches:02}\u2033",
"color": "black"
},
{
"id": "dex_weight",
"type": "text",
"rect": { "tx": 9, "ty": 6, "tw": 10, "th": 1 },
"value": "WT {weight%10}.{weight%10} lb",
"color": "black"
},
{
"id": "dex_desc",
"type": "text",
"rect": { "tx": 1, "ty": 11, "tw": 18, "th": 6 },
"value": "{description}",
"color": "black",
"wrap": true
}
]
}"##;
let layout = parse_layout(json).expect("dex layout should parse");
assert_eq!(layout.screen, "dex");
assert_eq!(layout.elements.len(), 9);
let ids: Vec<&str> = layout.elements.iter().map(|e| e.id.as_str()).collect();
assert!(ids.contains(&"dex_border"));
assert!(ids.contains(&"dex_title"));
assert!(ids.contains(&"dex_image"));
assert!(ids.contains(&"dex_name"));
assert!(ids.contains(&"dex_desc"));
}
#[test]
fn test_load_layout_not_found() {
let result = load_layout("__nonexistent_layout_xyzzy__");
assert!(result.is_err(), "missing file should error");
match result {
Err(RenderError::InvalidLayout) => {} other => panic!("expected InvalidLayout, got {:?}", other),
}
}
#[test]
fn test_parse_minimal_layout() {
let json = r##"{
"schema_version": 1,
"screen": "minimal",
"elements": []
}"##;
let layout = parse_layout(json).expect("minimal should parse");
assert_eq!(layout.screen, "minimal");
assert_eq!(layout.elements.len(), 0);
}
#[test]
fn test_parse_group_layout() {
let json = r##"{
"schema_version": 1,
"screen": "grouped",
"theme": { "bg_color": "#FFFFFF", "default_font": "default" },
"elements": [
{
"id": "container",
"type": "group",
"rect": { "tx": 2, "ty": 2, "tw": 16, "th": 14 },
"layout": {
"direction": "Vertical",
"gap": 2,
"padding": { "top": 1, "bottom": 1, "left": 1, "right": 1 }
},
"clip": true,
"children": [
{
"type": "text",
"rect": { "tx": 0, "ty": 0, "tw": 14, "th": 2 },
"value": "ITEM",
"color": "black"
},
{
"type": "text",
"rect": { "tx": 0, "ty": 2, "tw": 14, "th": 2 },
"value": "DESCRIPTION",
"color": "darkgray",
"wrap": true
}
]
}
]
}"##;
let layout = parse_layout(json).expect("group layout should parse");
assert_eq!(layout.elements.len(), 1);
assert_eq!(layout.elements[0].element_type, "group");
assert_eq!(layout.elements[0].id, "container");
if let crate::layout_engine::types::ElementParams::Group(ref gp) =
layout.elements[0].params
{
assert_eq!(gp.children.len(), 2);
assert!(gp.clip);
assert!(matches!(
gp.layout.direction,
Some(crate::layout_engine::types::Direction::Vertical)
));
assert_eq!(gp.layout.gap, 2);
} else {
panic!("expected Group params");
}
}
#[test]
fn test_parse_custom_element() {
let json = r##"{
"schema_version": 1,
"screen": "custom",
"elements": [
{
"type": "custom:monster_sprite",
"rect": { "tx": 0, "ty": 0, "tw": 7, "th": 7 },
"sprite_id": 25,
"palette": "default"
}
]
}"##;
let layout = parse_layout(json).expect("custom element should parse");
assert_eq!(layout.elements.len(), 1);
assert_eq!(layout.elements[0].element_type, "custom:monster_sprite");
}
#[test]
fn test_parse_z_index_default() {
let json = r##"{
"schema_version": 1,
"screen": "z_test",
"elements": [
{ "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "bg" },
{ "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "fg", "z_index": 10 }
]
}"##;
let layout = parse_layout(json).expect("should parse");
assert_eq!(layout.elements[0].z_index, 0); assert_eq!(layout.elements[1].z_index, 10);
}
#[test]
fn test_parse_visible_default_and_explicit() {
let json = r##"{
"schema_version": 1,
"screen": "vis_test",
"elements": [
{ "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "visible" },
{ "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "hidden", "visible": false }
]
}"##;
let layout = parse_layout(json).expect("should parse");
let ctx = crate::layout_engine::types::DataContext::new();
assert!(layout.elements[0].visible.eval(&ctx)); assert!(!layout.elements[1].visible.eval(&ctx)); }
#[test]
fn test_parse_flex_list_layout() {
let json = r##"{
"schema_version": 1,
"screen": "mart",
"elements": [
{
"type": "flex_list",
"rect": { "tx": 1, "ty": 3, "tw": 18, "th": 12 },
"items": "{inventory}",
"cursor": 0,
"gap": 0,
"padding": { "top": 0, "bottom": 0, "left": 1, "right": 0 },
"item_layout": [
{ "field": "name", "width": 12, "align": "Left" },
{ "field": "price", "width": 5, "align": "Right", "prefix": "$" }
]
}
]
}"##;
let layout = parse_layout(json).expect("flex_list should parse");
assert_eq!(layout.elements.len(), 1);
assert_eq!(layout.elements[0].element_type, "flex_list");
}
}