use std::collections::{HashMap, HashSet};
use egui::{TextureHandle, Ui};
use a2ui_base::catalog::function_api::FunctionImplementation;
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::model::component_model::ComponentModel;
use a2ui_base::model::components_model::SurfaceComponentsModel;
use a2ui_base::model::data_model::DataModel;
use a2ui_base::protocol::common_types::{
ChildList, DynamicBoolean, DynamicNumber, DynamicString, DynamicStringList, DynamicValue,
};
use serde_json::Value;
use crate::edit_state::EditBuffers;
use crate::interaction::PendingInteraction;
use crate::walker::render_node;
pub(super) struct Walk<'a> {
pub surface_id: &'a str,
pub data_model: &'a DataModel,
pub components: &'a SurfaceComponentsModel,
pub functions: &'a HashMap<String, Box<dyn FunctionImplementation>>,
pub focused_id: Option<&'a str>,
pub open_modals: &'a HashSet<String>,
pub image_cache: &'a HashMap<String, Option<TextureHandle>>,
pub local_tabs: &'a HashMap<String, usize>,
}
#[allow(clippy::too_many_arguments)]
fn render_child(
walk: &Walk<'_>,
ui: &mut Ui,
edit_buffers: &mut EditBuffers,
pending: &mut Vec<PendingInteraction>,
child_id: &str,
base_path: &str,
) {
render_node(
child_id,
walk.surface_id,
base_path,
ui,
walk.data_model,
walk.components,
walk.functions,
walk.focused_id,
walk.open_modals,
walk.image_cache,
walk.local_tabs,
edit_buffers,
pending,
);
}
fn build_child_plan(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.clone(), 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
}
pub(super) fn render_column(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
ui.vertical(|ui| {
for (child_id, child_base) in build_child_plan(model, ctx) {
render_child(walk, ui, eb, p, &child_id, &child_base);
}
});
}
pub(super) fn render_row(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
ui.horizontal(|ui| {
for (child_id, child_base) in build_child_plan(model, ctx) {
render_child(walk, ui, eb, p, &child_id, &child_base);
}
});
}
pub(super) fn render_card(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
egui::Frame::group(ui.style())
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(208)))
.corner_radius(8.0)
.inner_margin(10.0)
.show(ui, |ui| {
for (child_id, child_base) in build_child_plan(model, ctx) {
render_child(walk, ui, eb, p, &child_id, &child_base);
}
});
}
pub(super) fn render_modal(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
_ctx: &ComponentContext, model: &ComponentModel,
) {
if let Some(trigger_id) = model.get_property::<String>("trigger") {
render_child(walk, ui, eb, p, &trigger_id, "");
}
}
pub(super) fn render_text(ui: &mut Ui, ctx: &ComponentContext, model: &ComponentModel) {
let text = model
.get_property::<DynamicString>("text")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let variant: Option<String> = model.get_property("variant");
let richtext = if matches!(variant.as_deref(), Some("h1") | Some("h2") | Some("h3")) {
egui::RichText::new(text).strong().heading()
} else {
egui::RichText::new(text)
};
ui.label(richtext);
}
pub(super) fn render_divider(ui: &mut Ui) {
ui.separator();
}
pub(super) fn render_button(
walk: &Walk<'_>, ui: &mut Ui, _eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let label = resolve_child_text(ctx, model).unwrap_or_else(|| {
model
.accessibility()
.and_then(|a| a.label)
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default()
});
let variant: Option<String> = model.get_property("variant");
let checks_pass = evaluate_checks(ctx, model);
let label_richtext = match variant.as_deref() {
Some("borderless") => egui::RichText::new(label).underline(),
other => {
let rt = egui::RichText::new(label);
match other {
Some("primary") => rt.strong(),
_ => rt,
}
}
};
let button = egui::Button::new(label_richtext);
let button = match variant.as_deref() {
Some("primary") => button
.fill(egui::Color32::from_rgb(37, 99, 235))
.stroke(egui::Stroke::NONE),
Some("borderless") => button.fill(egui::Color32::TRANSPARENT).stroke(egui::Stroke::NONE),
_ => button,
};
let response = ui.add_enabled(checks_pass, button);
if response.clicked() {
p.push(PendingInteraction::ButtonActivate {
component_id: ctx.component_id.clone(),
});
}
let _ = walk;
}
pub(super) fn render_text_field(
_walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let value_binding = model.get_property::<DynamicString>("value");
let resolved = value_binding
.as_ref()
.map(|ds| ctx.data_context.resolve_dynamic_string(ds))
.unwrap_or_default();
if !label.is_empty() {
ui.label(egui::RichText::new(&label).weak().small());
}
let focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
let buf = eb.text_buffer(&ctx.component_id, &resolved, focused);
let before = buf.clone();
ui.text_edit_singleline(buf);
if buf != &before
&& let Some(DynamicString::Binding(b)) = &value_binding
{
p.push(PendingInteraction::DataUpdate {
path: ctx.data_context.resolve_pointer(&b.path),
value: serde_json::Value::String(buf.clone()),
});
}
}
pub(super) fn render_checkbox(
_walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let value_binding = model.get_property::<DynamicBoolean>("value");
let resolved = value_binding
.as_ref()
.map(|db| ctx.data_context.resolve_dynamic_boolean(db))
.unwrap_or(false);
let checked = eb.boolean_buffer(&ctx.component_id, resolved);
let before = *checked;
ui.checkbox(checked, &label);
if *checked != before
&& let Some(DynamicBoolean::Binding(b)) = &value_binding
{
p.push(PendingInteraction::DataUpdate {
path: ctx.data_context.resolve_pointer(&b.path),
value: serde_json::Value::Bool(*checked),
});
}
}
pub(super) fn render_slider(
_walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let value_binding = model.get_property::<DynamicNumber>("value");
let resolved = value_binding
.as_ref()
.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();
let val = eb.number_buffer(&ctx.component_id, resolved);
let before = *val;
ui.add(egui::Slider::new(val, 0.0..=100.0).text(&label));
if (*val - before).abs() > f64::EPSILON
&& let Some(DynamicNumber::Binding(b)) = &value_binding
{
p.push(PendingInteraction::DataUpdate {
path: ctx.data_context.resolve_pointer(&b.path),
value: serde_json::json!(*val),
});
}
}
pub(super) fn render_choice_picker(
_walk: &Walk<'_>, ui: &mut Ui, _eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let options = read_options(model);
let value_binding = model.get_property::<DynamicStringList>("value");
let selected_values = value_binding
.as_ref()
.map(|dsl| resolve_choice_value(ctx, dsl))
.unwrap_or_default();
let path: Option<String> = match &value_binding {
Some(DynamicStringList::Binding(b)) => Some(ctx.data_context.resolve_pointer(&b.path)),
_ => None,
};
if !label.is_empty() {
ui.label(egui::RichText::new(&label).weak().small());
}
if options.is_empty() {
return;
}
let is_multiple = model
.get_property::<String>("variant")
.as_deref()
.map(|v| v == "multipleSelection")
.unwrap_or(false);
if is_multiple {
ui.vertical(|ui| {
for (opt_label, opt_value) in &options {
let mut checked = selected_values.contains(opt_value);
let response = ui.checkbox(&mut checked, opt_label);
if response.changed()
&& let Some(path) = &path
{
let mut next = selected_values.clone();
if checked {
if !next.contains(opt_value) {
next.push(opt_value.clone());
}
} else {
next.retain(|v| v != opt_value);
}
p.push(PendingInteraction::DataUpdate {
path: path.clone(),
value: serde_json::json!(next),
});
}
}
});
} else {
let selected_label = selected_values
.first()
.and_then(|v| {
options
.iter()
.find(|(_, val)| val == v)
.map(|(lbl, _)| lbl.clone())
});
let mut picked: Option<String> = None;
egui::ComboBox::from_id_salt(format!("{}_choice", ctx.component_id))
.selected_text(selected_label.unwrap_or_default())
.show_ui(ui, |ui| {
for (opt_label, opt_value) in &options {
let is_sel = selected_values.first() == Some(opt_value);
if ui.selectable_label(is_sel, opt_label).clicked() {
picked = Some(opt_value.clone());
}
}
});
if let Some(value) = picked
&& let Some(path) = &path
{
p.push(PendingInteraction::DataUpdate {
path: path.clone(),
value: serde_json::json!([value]),
});
}
}
}
pub(super) fn render_tabs(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let tabs = read_tabs(model);
if tabs.is_empty() {
return;
}
let active_dn = model.get_property::<DynamicNumber>("activeTab");
let active_path: Option<String> = active_dn.as_ref().and_then(|dn| match dn {
DynamicNumber::Binding(b) => Some(ctx.data_context.resolve_pointer(&b.path)),
_ => None,
});
let active = match &active_dn {
Some(dn) => ctx.data_context.resolve_dynamic_number(dn) as usize,
None => walk.local_tabs.get(&ctx.component_id).copied().unwrap_or(0),
}
.min(tabs.len() - 1);
ui.horizontal(|ui| {
for (i, (title, _child)) in tabs.iter().enumerate() {
let is_active = i == active;
let title_str = ctx.data_context.resolve_dynamic_string(title);
if ui.selectable_label(is_active, &title_str).clicked() {
match &active_path {
Some(path) => p.push(PendingInteraction::DataUpdate {
path: path.clone(),
value: serde_json::json!(i),
}),
None => p.push(PendingInteraction::TabActivate {
component_id: ctx.component_id.clone(),
index: i,
}),
}
}
}
});
ui.separator();
let active_child = tabs[active].1.clone();
let child_base = ctx.data_context.base_path().to_string();
render_child(walk, ui, eb, p, &active_child, &child_base);
}
pub(super) fn render_icon(ui: &mut Ui, ctx: &ComponentContext, model: &ComponentModel) {
let name = model
.get_property::<DynamicString>("name")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
ui.label(
egui::RichText::new(map_icon_emoji(&name))
.size(18.0)
.family(icon_family()),
);
}
pub(super) fn render_date_time_input(
_walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
let label = model
.get_property::<DynamicString>("label")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let value_binding = model.get_property::<DynamicString>("value");
let resolved = value_binding
.as_ref()
.map(|ds| ctx.data_context.resolve_dynamic_string(ds))
.unwrap_or_default();
let enable_date: bool = model.get_property("enableDate").unwrap_or(true);
let enable_time: bool = model.get_property("enableTime").unwrap_or(true);
let hint = match (enable_date, enable_time) {
(true, true) => "YYYY-MM-DDTHH:MM:SS",
(true, false) => "YYYY-MM-DD",
(false, true) => "HH:MM:SS",
(false, false) => "ISO datetime",
};
if !label.is_empty() {
ui.label(egui::RichText::new(format!("{label}: {hint}")).weak().small());
}
let focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
let buf = eb.text_buffer(&ctx.component_id, &resolved, focused);
let before = buf.clone();
ui.text_edit_singleline(buf);
if buf != &before
&& let Some(DynamicString::Binding(b)) = &value_binding
{
p.push(PendingInteraction::DataUpdate {
path: ctx.data_context.resolve_pointer(&b.path),
value: serde_json::Value::String(buf.clone()),
});
}
}
pub(super) fn render_image(
walk: &Walk<'_>, ui: &mut Ui, ctx: &ComponentContext, model: &ComponentModel,
) {
let url = model
.get_property::<DynamicString>("url")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let description = model
.get_property::<DynamicString>("description")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
if let Some(Some(handle)) = walk.image_cache.get(&url) {
ui.add(egui::Image::from_texture(handle).max_width(480.0));
return;
}
let label = if description.is_empty() { "image" } else { &description };
ui.label(format!("🖼 image · {label}"));
}
pub(super) fn render_media_placeholder(
ui: &mut Ui, kind: &str, ctx: &ComponentContext, model: &ComponentModel,
) {
let url = model
.get_property::<DynamicString>("url")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let glyph = match kind {
"Video" => "▷",
"Audio" => "♪",
_ => "◆",
};
ui.label(format!("{glyph} {kind} · {url}"));
}
fn read_tabs(model: &ComponentModel) -> Vec<(DynamicString, String)> {
let Some(arr) = model.get_raw("tabs").and_then(Value::as_array) else {
return Vec::new();
};
arr.iter().filter_map(parse_tab_entry).collect()
}
fn parse_tab_entry(v: &Value) -> Option<(DynamicString, String)> {
let child = v.get("child")?.as_str()?.to_string();
let title = serde_json::from_value::<DynamicString>(v.get("title")?.clone()).ok()?;
Some((title, child))
}
fn read_options(model: &ComponentModel) -> Vec<(String, String)> {
let Some(arr) = model.get_raw("options").and_then(Value::as_array) else {
return Vec::new();
};
arr.iter().filter_map(parse_choice_option).collect()
}
fn parse_choice_option(v: &Value) -> Option<(String, String)> {
let label = v.get("label")?.as_str()?.to_string();
let value = v
.get("value")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some((label, value))
}
fn resolve_choice_value(ctx: &ComponentContext, dsl: &DynamicStringList) -> Vec<String> {
match dsl {
DynamicStringList::Literal(v) => v.clone(),
DynamicStringList::Binding(b) => match ctx.data_context.get(&b.path) {
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
Some(Value::String(s)) => vec![s.clone()],
_ => Vec::new(),
},
DynamicStringList::Function(fc) => {
match ctx
.data_context
.resolve_dynamic_value(&DynamicValue::Function(fc.clone()))
{
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
Value::String(s) => vec![s],
_ => Vec::new(),
}
}
}
}
fn map_icon_emoji(name: &str) -> String {
let glyph = match name {
"mail" => "📧",
"send" => "📤",
"search" => "🔍",
"settings" => "⚙",
"star" => "⭐",
"accountCircle" | "person" => "👤",
"home" => "🏠",
"heart" | "favorite" => "❤",
"check" => "✅",
"close" => "❌",
"add" => "➕",
"remove" => "➖",
"edit" => "✏",
"delete" => "🗑",
"refresh" => "🔄",
"arrowBack" => "⬅",
"arrowForward" => "➡",
"arrowUp" | "up" => "⬆",
"arrowDown" | "down" => "⬇",
"info" => "ℹ",
"warning" => "⚠",
"error" => "⛔",
"success" => "✅",
"calendarToday" => "📅",
"locationOn" => "📍",
"payment" => "💳",
"phone" => "📞",
"play" => "▶",
"pause" => "⏸",
"stop" => "⏹",
"skipNext" | "next" => "⏭",
"skipPrevious" | "previous" => "⏮",
_ => return format!("[{}]", name.chars().take(2).collect::<String>()),
};
glyph.to_string()
}
pub(super) fn icon_family() -> egui::FontFamily {
egui::FontFamily::Name(std::sync::Arc::from("Icons"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_tab_entry_literal_title() {
let v = serde_json::json!({ "title": "Overview", "child": "overview-col" });
let (title, child) = parse_tab_entry(&v).expect("valid entry");
assert_eq!(child, "overview-col");
assert_eq!(title, DynamicString::Literal("Overview".to_string()));
}
#[test]
fn parse_tab_entry_bound_title() {
let v = serde_json::json!({ "title": { "path": "/title" }, "child": "c1" });
let (title, child) = parse_tab_entry(&v).expect("valid entry");
assert_eq!(child, "c1");
assert!(matches!(title, DynamicString::Binding(_)));
}
#[test]
fn parse_tab_entry_missing_child_is_skipped() {
let v = serde_json::json!({ "title": "Overview" });
assert!(parse_tab_entry(&v).is_none());
}
#[test]
fn parse_choice_option_defaults_value_to_empty() {
let v = serde_json::json!({ "label": "Code" });
let (label, value) = parse_choice_option(&v).expect("valid option");
assert_eq!(label, "Code");
assert_eq!(value, "");
}
#[test]
fn parse_choice_option_full() {
let v = serde_json::json!({ "label": "Grand Ballroom", "value": "ballroom" });
let (label, value) = parse_choice_option(&v).expect("valid option");
assert_eq!(label, "Grand Ballroom");
assert_eq!(value, "ballroom");
}
#[test]
fn parse_choice_option_missing_label_is_skipped() {
let v = serde_json::json!({ "value": "ballroom" });
assert!(parse_choice_option(&v).is_none());
}
#[test]
fn map_icon_emoji_known_name() {
assert_eq!(map_icon_emoji("mail"), "📧");
assert_eq!(map_icon_emoji("star"), "⭐");
assert_eq!(map_icon_emoji("settings"), "⚙");
assert_eq!(map_icon_emoji("person"), map_icon_emoji("accountCircle"));
}
#[test]
fn map_icon_emoji_unknown_falls_back_to_bracketed_prefix() {
assert_eq!(map_icon_emoji("XYZ"), "[XY]");
assert_eq!(map_icon_emoji("k"), "[k]");
}
}
pub(super) fn render_unknown(
walk: &Walk<'_>, ui: &mut Ui, eb: &mut EditBuffers, p: &mut Vec<PendingInteraction>,
ctx: &ComponentContext, model: &ComponentModel,
) {
ui.label(format!("[{}]", model.component_type));
for (child_id, child_base) in build_child_plan(model, ctx) {
render_child(walk, ui, eb, p, &child_id, &child_base);
}
}
fn resolve_child_text(ctx: &ComponentContext, model: &ComponentModel) -> Option<String> {
let child_id = model.child()?;
let child = ctx.components.get(&child_id)?;
if child.component_type != "Text" {
return None;
}
child
.get_property::<DynamicString>("text")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
}
fn evaluate_checks(ctx: &ComponentContext, model: &ComponentModel) -> bool {
match model.checks() {
Some(checks) => checks
.iter()
.all(|rule| ctx.data_context.resolve_dynamic_boolean_condition(&rule.condition)),
None => true,
}
}