use std::collections::HashMap;
use a2ui_base::catalog::function_api::FunctionImplementation;
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::model::components_model::SurfaceComponentsModel;
use a2ui_base::model::data_model::DataModel;
use a2ui_base::model::surface_model::SurfaceModel;
use a2ui_base::protocol::common_types::{ChildList, DynamicBoolean, DynamicNumber, DynamicString};
use crate::ui::LiveNode;
pub fn build_nodes(
surface: &SurfaceModel,
functions: &HashMap<String, Box<dyn FunctionImplementation>>,
focused_id: Option<&str>,
) -> Vec<LiveNode> {
let data_model = surface.data_model.borrow();
let components = surface.components.borrow();
if !components.contains("root") {
return Vec::new();
}
let mut builder = FlatBuilder { nodes: Vec::new() };
builder.add(
"root",
"",
&data_model,
&components,
functions,
&surface.id,
focused_id,
);
builder.nodes
}
struct FlatBuilder {
nodes: Vec<LiveNode>,
}
impl FlatBuilder {
#[allow(clippy::too_many_arguments)]
fn add(
&mut self,
id: &str,
base_path: &str,
data_model: &DataModel,
components: &SurfaceComponentsModel,
functions: &HashMap<String, Box<dyn FunctionImplementation>>,
surface_id: &str,
focused_id: Option<&str>,
) -> Option<usize> {
let model = components.get(id)?;
let kind = model.component_type.clone();
let (text, label, variant, checked, number, extra, child_plan) = {
let ctx = ComponentContext::new(
id.to_string(),
surface_id.to_string(),
data_model,
components,
functions,
base_path,
focused_id.map(|s| s.to_string()),
);
let (text, label, variant, checked, number, extra) = resolve_fields(&kind, &ctx, model);
let plan = build_child_plan(model, &ctx);
(text, label, variant, checked, number, extra, plan)
};
let idx = self.nodes.len();
self.nodes.push(empty_node());
let mut child_indices: Vec<i32> = Vec::new();
for (child_id, child_base) in child_plan {
if let Some(child_idx) =
self.add(&child_id, &child_base, data_model, components, functions, surface_id, focused_id)
{
child_indices.push(child_idx as i32);
}
}
self.nodes[idx] = LiveNode {
id: id.into(),
kind: kind.into(),
text: text.into(),
label: label.into(),
variant: variant.into(),
checked,
number: number as f32,
extra: extra.into(),
focused: focused_id == Some(id),
children: to_int_model(child_indices),
};
Some(idx)
}
}
fn resolve_fields(
kind: &str,
ctx: &ComponentContext,
model: &a2ui_base::model::component_model::ComponentModel,
) -> (String, String, String, bool, f64, String) {
let variant: String = model.get_property::<String>("variant").unwrap_or_default();
match kind {
"Text" => {
let text = model
.get_property::<DynamicString>("text")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(text, String::new(), variant, false, 0.0, String::new())
}
"TextField" => {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let value = model
.get_property::<DynamicString>("value")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(value, label, variant, false, 0.0, String::new())
}
"CheckBox" => {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let checked = model
.get_property::<DynamicBoolean>("value")
.map(|db| ctx.data_context.resolve_dynamic_boolean(&db))
.unwrap_or(false);
(label, String::new(), variant, checked, 0.0, String::new())
}
"Slider" => {
let number = model
.get_property::<DynamicNumber>("value")
.map(|dn| ctx.data_context.resolve_dynamic_number(&dn))
.unwrap_or(0.0);
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(String::new(), label, variant, false, number, String::new())
}
"Tabs" => {
let number = model
.get_property::<DynamicNumber>("activeTab")
.map(|dn| ctx.data_context.resolve_dynamic_number(&dn))
.unwrap_or(0.0);
(String::new(), String::new(), variant, false, number, String::new())
}
"Icon" => {
let name = model
.get_property::<DynamicString>("name")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(String::new(), String::new(), variant, false, 0.0, name)
}
"DateTimeInput" => {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let value = model
.get_property::<DynamicString>("value")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(String::new(), label, variant, false, 0.0, value)
}
"Image" | "Video" | "AudioPlayer" => {
let url = model
.get_property::<DynamicString>("url")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(String::new(), String::new(), variant, false, 0.0, url)
}
"ChoicePicker" => {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
(String::new(), label, variant, false, 0.0, String::new())
}
"Modal" => {
let is_open = model
.get_property::<DynamicBoolean>("isOpen")
.map(|db| ctx.data_context.resolve_dynamic_boolean(&db))
.unwrap_or(false);
(String::new(), String::new(), variant, is_open, 0.0, String::new())
}
_ => (String::new(), String::new(), variant, false, 0.0, String::new()),
}
}
fn build_child_plan(
model: &a2ui_base::model::component_model::ComponentModel,
ctx: &ComponentContext,
) -> Vec<(String, String)> {
let mut plan = Vec::new();
let base = ctx.data_context.base_path().to_string();
if let Some(child_id) = model.child() {
plan.push((child_id, base.clone()));
}
match model.children() {
Some(ChildList::Static(ids)) => {
for cid in ids {
plan.push((cid, base.clone()));
}
}
Some(ChildList::Template { component_id, path }) => {
if let Some(serde_json::Value::Array(arr)) = ctx.data_context.get(&path) {
for i in 0..arr.len() {
plan.push((component_id.clone(), format!("{path}/{i}")));
}
}
}
None => {}
}
plan
}
fn to_int_model(indices: Vec<i32>) -> slint::ModelRc<i32> {
slint::ModelRc::new(std::rc::Rc::new(slint::VecModel::from(indices)))
}
fn empty_node() -> LiveNode {
LiveNode {
id: "".into(),
kind: "".into(),
text: "".into(),
label: "".into(),
variant: "".into(),
checked: false,
number: 0.0,
extra: "".into(),
focused: false,
children: slint::ModelRc::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2ui_base::catalog::Catalog;
use a2ui_base::message_processor::MessageProcessor;
use slint::Model;
fn build(
components_json: serde_json::Value,
data: Option<serde_json::Value>,
focused_id: Option<&str>,
) -> Vec<LiveNode> {
let mut processor = MessageProcessor::new(vec![Catalog::new(
"https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
)]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": {
"surfaceId": "test",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"dataModel": data.unwrap_or(serde_json::Value::Object(Default::default())),
}
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let update = serde_json::json!({
"version": "v1.0",
"updateComponents": { "surfaceId": "test", "components": components_json }
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("test").expect("surface exists");
let functions = HashMap::new();
build_nodes(surface, &functions, focused_id)
}
fn child_ids(nodes: &[LiveNode], idx: usize) -> Vec<i32> {
nodes[idx].children.iter().collect()
}
#[test]
fn no_root_yields_empty() {
let mut processor = MessageProcessor::new(vec![Catalog::new("c")]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": { "surfaceId": "s", "catalogId": "c", "dataModel": {} }
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("s").unwrap();
assert!(build_nodes(surface, &HashMap::new(), None).is_empty());
}
#[test]
fn text_root_resolves_to_single_node() {
let nodes = build(
serde_json::json!([{ "id": "root", "component": "Text", "text": "Hello" }]),
None,
None,
);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].kind.to_string(), "Text");
assert_eq!(nodes[0].text.to_string(), "Hello");
assert_eq!(nodes[0].children.iter().count(), 0);
}
#[test]
fn column_children_are_indexed_after_root() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": ["a", "b"] },
{ "id": "a", "component": "Text", "text": "One" },
{ "id": "b", "component": "Text", "text": "Two" }
]),
None,
None,
);
assert_eq!(nodes.len(), 3);
assert_eq!(nodes[0].kind.to_string(), "Column");
assert_eq!(child_ids(&nodes, 0), vec![1, 2]);
assert_eq!(nodes[1].text.to_string(), "One");
assert_eq!(nodes[2].text.to_string(), "Two");
}
#[test]
fn nested_card_button_uses_indices() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Card", "child": "btn" },
{ "id": "btn", "component": "Button", "child": "lbl" },
{ "id": "lbl", "component": "Text", "text": "Sign In" }
]),
None,
None,
);
assert_eq!(nodes.len(), 3);
assert_eq!(child_ids(&nodes, 0), vec![1]);
assert_eq!(child_ids(&nodes, 1), vec![2]);
assert_eq!(nodes[1].kind.to_string(), "Button");
assert_eq!(nodes[2].text.to_string(), "Sign In");
}
#[test]
fn template_children_expand_and_resolve_own_path() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": { "path": "/items", "componentId": "item" } },
{ "id": "item", "component": "Text", "text": { "path": "name" } }
]),
Some(serde_json::json!({ "items": [
{ "name": "Alpha" }, { "name": "Beta" }, { "name": "Gamma" }
]})),
None,
);
assert_eq!(nodes.len(), 4); assert_eq!(child_ids(&nodes, 0), vec![1, 2, 3]);
let texts: Vec<String> = nodes[1..].iter().map(|n| n.text.to_string()).collect();
assert_eq!(texts, vec!["Alpha", "Beta", "Gamma"]);
}
#[test]
fn focused_flag_marks_only_the_focused_node() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": ["f", "o"] },
{ "id": "f", "component": "TextField", "label": "L", "value": "" },
{ "id": "o", "component": "TextField", "label": "L2", "value": "" }
]),
None,
Some("f"),
);
assert!(!nodes[0].focused, "root not focused");
assert!(nodes[1].focused, "first field focused");
assert!(!nodes[2].focused);
}
#[test]
fn checkbox_carries_checked_from_data_model() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": ["c"] },
{ "id": "c", "component": "CheckBox", "label": "Agree", "value": { "path": "/flag" } }
]),
Some(serde_json::json!({ "flag": true })),
None,
);
assert_eq!(nodes[1].kind.to_string(), "CheckBox");
assert_eq!(nodes[1].text.to_string(), "Agree");
assert!(nodes[1].checked, "checked reflects data-model bool");
}
#[test]
fn slider_carries_number_from_data_model() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": ["s"] },
{ "id": "s", "component": "Slider", "value": { "path": "/vol" } }
]),
Some(serde_json::json!({ "vol": 42 })),
None,
);
assert_eq!(nodes[1].kind.to_string(), "Slider");
assert!(((nodes[1].number as f64) - 42.0).abs() < 1e-6);
}
#[test]
fn image_carries_source_in_extra() {
let nodes = build(
serde_json::json!([
{ "id": "root", "component": "Column", "children": ["img"] },
{ "id": "img", "component": "Image", "url": "https://example.com/a.png" }
]),
None,
None,
);
assert_eq!(nodes[1].kind.to_string(), "Image");
assert_eq!(nodes[1].extra.to_string(), "https://example.com/a.png");
}
}