use serde::{Deserialize, Serialize};
use crate::virtual_scroll::VirtualScroll;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UITree<Msg> {
pub kind: NodeKind<Msg>,
pub meta: NodeMeta<Msg>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NodeKind<Msg> {
Container { children: Vec<UITree<Msg>> },
Heading { level: u8, text: String },
Text { text: String },
Button { label: String },
Input { value: String },
Textarea { value: String },
Checkbox { label: String, checked: bool },
Select {
options: Vec<(String, String)>,
selected: String,
},
Radio {
name: String,
options: Vec<(String, String)>,
selected: String,
},
List { items: Vec<UITree<Msg>> },
DataGrid {
columns: Vec<String>,
rows: Vec<Vec<String>>,
},
Portal {
target: String,
content: Box<UITree<Msg>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AiMeta {
pub action: Option<String>,
pub params: Vec<(String, String)>,
pub description: Option<String>,
}
pub type OnInput<Msg> = std::sync::Arc<dyn Fn(String) -> Msg + Send + Sync>;
pub type OnToggle<Msg> = std::sync::Arc<dyn Fn(bool) -> Msg + Send + Sync>;
fn on_input_default<Msg>() -> Option<OnInput<Msg>> {
None
}
fn on_toggle_default<Msg>() -> Option<OnToggle<Msg>> {
None
}
#[derive(Clone, Serialize, Deserialize)]
pub struct NodeMeta<Msg> {
pub class: Option<String>,
pub on_click: Option<Msg>,
#[serde(skip, default = "on_input_default")]
pub on_input: Option<OnInput<Msg>>,
#[serde(skip, default = "on_toggle_default")]
pub on_toggle: Option<OnToggle<Msg>>,
pub ai: AiMeta,
pub data_appfront_id: Option<u64>,
#[serde(default)]
pub is_dynamic: bool,
#[serde(default)]
pub attrs: Vec<(String, String)>,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub virtual_scroll: Option<VirtualScroll>,
}
impl<Msg> Default for NodeMeta<Msg> {
fn default() -> Self {
NodeMeta {
class: None,
on_click: None,
on_input: None,
on_toggle: None,
ai: AiMeta::default(),
data_appfront_id: None,
is_dynamic: false,
attrs: Vec::new(),
key: None,
virtual_scroll: None,
}
}
}
impl<Msg: std::fmt::Debug> std::fmt::Debug for NodeMeta<Msg> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeMeta")
.field("class", &self.class)
.field("on_click", &self.on_click)
.field("on_input", &self.on_input.as_ref().map(|_| "<fn>"))
.field("on_toggle", &self.on_toggle.as_ref().map(|_| "<fn>"))
.field("ai", &self.ai)
.field("data_appfront_id", &self.data_appfront_id)
.field("is_dynamic", &self.is_dynamic)
.field("key", &self.key)
.field("virtual_scroll", &self.virtual_scroll)
.finish()
}
}
impl<Msg> UITree<Msg> {
fn leaf(kind: NodeKind<Msg>) -> Self {
UITree {
kind,
meta: NodeMeta::default(),
}
}
pub fn container(build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> Self {
let mut builder = ContainerBuilder { children: Vec::new() };
build(&mut builder);
UITree::leaf(NodeKind::Container {
children: builder.children,
})
}
pub fn meta_mut(&mut self) -> &mut NodeMeta<Msg> {
&mut self.meta
}
pub fn collect_portals(&self, target: &str) -> Vec<UITree<Msg>>
where
Msg: Clone,
{
fn walk<Msg: Clone>(ui: &UITree<Msg>, target: &str, out: &mut Vec<UITree<Msg>>) {
match &ui.kind {
NodeKind::Container { children } => {
for child in children {
walk(child, target, out);
}
}
NodeKind::List { items } => {
for item in items {
walk(item, target, out);
}
}
NodeKind::Portal {
target: t,
content,
} => {
if t == target {
out.push((**content).clone());
} else {
walk(content, target, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(self, target, &mut out);
out
}
pub fn portal_targets(&self) -> std::collections::BTreeSet<String> {
fn walk<Msg>(ui: &UITree<Msg>, out: &mut std::collections::BTreeSet<String>) {
match &ui.kind {
NodeKind::Container { children } => {
for child in children {
walk(child, out);
}
}
NodeKind::List { items } => {
for item in items {
walk(item, out);
}
}
NodeKind::Portal { target, content } => {
out.insert(target.clone());
walk(content, out);
}
_ => {}
}
}
let mut out = std::collections::BTreeSet::new();
walk(self, &mut out);
out
}
pub fn assign_ids(&mut self) {
fn walk<Msg>(ui: &mut UITree<Msg>, next: &mut u64) {
ui.meta.data_appfront_id = Some(*next);
*next += 1;
match &mut ui.kind {
NodeKind::Container { children } => {
for child in children {
walk(child, next);
}
}
NodeKind::List { items } => {
for item in items {
walk(item, next);
}
}
NodeKind::Portal { content, .. } => {
walk(content, next);
}
NodeKind::DataGrid { .. }
| NodeKind::Heading { .. }
| NodeKind::Text { .. }
| NodeKind::Button { .. }
| NodeKind::Input { .. }
| NodeKind::Textarea { .. }
| NodeKind::Checkbox { .. }
| NodeKind::Select { .. }
| NodeKind::Radio { .. } => {}
}
}
walk(self, &mut 1);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HydrationPayload<Msg> {
pub tree: UITree<Msg>,
pub signals: std::collections::HashMap<String, serde_json::Value>,
}
pub struct ContainerBuilder<Msg> {
children: Vec<UITree<Msg>>,
}
impl<Msg> ContainerBuilder<Msg> {
pub fn new() -> Self {
ContainerBuilder {
children: Vec::new(),
}
}
pub fn into_only_child(self) -> Option<UITree<Msg>> {
if self.children.len() == 1 {
Some(self.children.into_iter().next().unwrap())
} else {
None
}
}
fn push(&mut self, kind: NodeKind<Msg>) -> NodeRef<'_, Msg> {
self.children.push(UITree::leaf(kind));
let index = self.children.len() - 1;
NodeRef {
children: &mut self.children,
index,
}
}
pub fn container(&mut self, build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> NodeRef<'_, Msg> {
let node = UITree::container(build);
self.children.push(node);
let index = self.children.len() - 1;
NodeRef {
children: &mut self.children,
index,
}
}
pub fn with(&mut self, node: UITree<Msg>) -> NodeRef<'_, Msg> {
self.children.push(node);
let index = self.children.len() - 1;
NodeRef {
children: &mut self.children,
index,
}
}
pub fn heading(&mut self, level: u8, text: impl Into<String>) -> NodeRef<'_, Msg> {
self.push(NodeKind::Heading {
level,
text: text.into(),
})
}
pub fn text(&mut self, text: impl Into<String>) -> NodeRef<'_, Msg> {
self.push(NodeKind::Text { text: text.into() })
}
pub fn button(&mut self, label: impl Into<String>) -> NodeRef<'_, Msg> {
self.push(NodeKind::Button {
label: label.into(),
})
}
pub fn input(&mut self, value: impl Into<String>) -> NodeRef<'_, Msg> {
self.push(NodeKind::Input {
value: value.into(),
})
}
pub fn textarea(&mut self, value: impl Into<String>) -> NodeRef<'_, Msg> {
self.push(NodeKind::Textarea {
value: value.into(),
})
}
pub fn checkbox(&mut self, label: impl Into<String>, checked: bool) -> NodeRef<'_, Msg> {
self.push(NodeKind::Checkbox {
label: label.into(),
checked,
})
}
pub fn select(
&mut self,
options: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
selected: impl Into<String>,
) -> NodeRef<'_, Msg> {
self.push(NodeKind::Select {
options: options
.into_iter()
.map(|(v, l)| (v.into(), l.into()))
.collect(),
selected: selected.into(),
})
}
pub fn radio_group(
&mut self,
name: impl Into<String>,
options: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
selected: impl Into<String>,
) -> NodeRef<'_, Msg> {
self.push(NodeKind::Radio {
name: name.into(),
options: options
.into_iter()
.map(|(v, l)| (v.into(), l.into()))
.collect(),
selected: selected.into(),
})
}
pub fn list(&mut self, build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> NodeRef<'_, Msg> {
let mut inner = ContainerBuilder { children: Vec::new() };
build(&mut inner);
self.push(NodeKind::List {
items: inner.children,
})
}
pub fn portal(
&mut self,
target: impl Into<String>,
build: impl FnOnce(&mut ContainerBuilder<Msg>),
) -> NodeRef<'_, Msg> {
let mut inner = ContainerBuilder { children: Vec::new() };
build(&mut inner);
let single = inner.into_only_child().unwrap_or_else(|| {
UITree::container(|_| {})
});
self.push(NodeKind::Portal {
target: target.into(),
content: Box::new(single),
})
}
pub fn data_grid(
&mut self,
columns: impl IntoIterator<Item = impl Into<String>>,
rows: impl IntoIterator<Item = impl IntoIterator<Item = impl Into<String>>>,
) -> NodeRef<'_, Msg> {
self.push(NodeKind::DataGrid {
columns: columns.into_iter().map(Into::into).collect(),
rows: rows
.into_iter()
.map(|row| row.into_iter().map(Into::into).collect())
.collect(),
})
}
}
impl<Msg> Default for ContainerBuilder<Msg> {
fn default() -> Self {
Self::new()
}
}
pub struct NodeRef<'a, Msg> {
children: &'a mut Vec<UITree<Msg>>,
index: usize,
}
impl<'a, Msg> NodeRef<'a, Msg> {
fn meta_mut(&mut self) -> &mut NodeMeta<Msg> {
self.children[self.index].meta_mut()
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.meta_mut().class = Some(class.into());
self
}
pub fn on_click(mut self, msg: Msg) -> Self {
self.meta_mut().on_click = Some(msg);
self
}
pub fn on_input(mut self, f: impl Fn(String) -> Msg + Send + Sync + 'static) -> Self {
self.meta_mut().on_input = Some(std::sync::Arc::new(f));
self
}
pub fn on_toggle(mut self, f: impl Fn(bool) -> Msg + Send + Sync + 'static) -> Self {
self.meta_mut().on_toggle = Some(std::sync::Arc::new(f));
self
}
pub fn ai_action(mut self, action: impl Into<String>) -> Self {
self.meta_mut().ai.action = Some(action.into());
self
}
pub fn ai_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.meta_mut().ai.params.push((key.into(), value.into()));
self
}
pub fn ai_description(mut self, desc: impl Into<String>) -> Self {
self.meta_mut().ai.description = Some(desc.into());
self
}
pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.meta_mut().attrs.push((name.into(), value.into()));
self
}
pub fn aria(self, name: impl Into<String>, value: impl Into<String>) -> Self {
let mut full = String::from("aria-");
full.push_str(&name.into());
self.attr(full, value)
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.meta_mut().key = Some(key.into());
self
}
pub fn virtual_scroll(mut self, config: VirtualScroll) -> Self {
self.meta_mut().virtual_scroll = Some(config);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
enum Event {
ExportData,
}
fn sample_ui() -> UITree<Event> {
UITree::container(|c| {
c.heading(1, "Dashboard").class("text-2xl font-bold");
c.data_grid(["Name", "Value"], [vec!["a", "1"], vec!["b", "2"]])
.class("w-full mt-4");
c.button("Export").on_click(Event::ExportData);
})
}
#[test]
fn builder_produces_expected_shape() {
let ui = sample_ui();
let NodeKind::Container { children } = ui.kind else {
panic!("expected container");
};
assert_eq!(children.len(), 3);
match &children[0].kind {
NodeKind::Heading { level, text } => {
assert_eq!(*level, 1);
assert_eq!(text, "Dashboard");
}
_ => panic!("expected heading"),
}
assert_eq!(
children[0].meta.class.as_deref(),
Some("text-2xl font-bold")
);
match &children[1].kind {
NodeKind::DataGrid { columns, rows } => {
assert_eq!(columns, &["Name", "Value"]);
assert_eq!(rows.len(), 2);
}
_ => panic!("expected data grid"),
}
match &children[2].kind {
NodeKind::Button { label } => assert_eq!(label, "Export"),
_ => panic!("expected button"),
}
assert_eq!(children[2].meta.on_click, Some(Event::ExportData));
}
#[test]
fn round_trips_through_json() {
let ui = sample_ui();
let json = serde_json::to_string(&ui).expect("serialize");
let restored: UITree<Event> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
format!("{restored:?}"),
format!("{:?}", ui),
"round-tripped tree should match the original"
);
}
#[test]
fn assign_ids_assigns_sequential_ids() {
let mut ui = UITree::container(|c| {
c.heading(2, "Section");
c.list(|l| {
l.text("item");
});
c.container(|inner| {
inner.button("Go").on_click(Event::ExportData);
});
});
ui.assign_ids();
assert_eq!(ui.meta.data_appfront_id, Some(1));
let NodeKind::Container { children } = &ui.kind else {
panic!("expected container");
};
assert_eq!(children[0].meta.data_appfront_id, Some(2));
assert_eq!(children[1].meta.data_appfront_id, Some(3));
let NodeKind::List { items } = &children[1].kind else {
panic!("expected list");
};
assert_eq!(items[0].meta.data_appfront_id, Some(4));
assert_eq!(children[2].meta.data_appfront_id, Some(5));
let NodeKind::Container { children: inner_children } = &children[2].kind else {
panic!("expected container");
};
assert_eq!(inner_children[0].meta.data_appfront_id, Some(6));
}
#[test]
fn hydration_payload_round_trips() {
let mut ui = sample_ui();
ui.assign_ids();
let mut signals = std::collections::HashMap::new();
signals.insert("count".to_string(), serde_json::json!(42));
let payload = HydrationPayload {
tree: ui,
signals: signals.clone(),
};
let json = serde_json::to_string(&payload).expect("serialize");
let restored: HydrationPayload<Event> =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.tree.meta.data_appfront_id, Some(1));
assert_eq!(restored.signals.get("count"), signals.get("count"));
}
}