use serde::Serialize;
use std::cell::RefCell;
use crate::signal::Signal;
use crate::ui_tree::{NodeKind, UITree};
#[derive(Debug, Clone, Serialize)]
pub struct AgentState {
pub interactive_elements: Vec<ElementSummary>,
pub data_elements: Vec<ElementSummary>,
pub current_route: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ElementSummary {
pub id: Option<u64>,
pub kind: String,
pub label: Option<String>,
pub value: Option<String>,
pub action: Option<String>,
pub params: Vec<(String, String)>,
pub description: Option<String>,
}
thread_local! {
static ROUTER: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
}
fn router() -> Signal<String> {
ROUTER.with(|r| {
r.borrow_mut()
.get_or_insert_with(|| Signal::new("/".to_string()))
.clone()
})
}
pub fn route_signal() -> Signal<String> {
router()
}
pub fn current_route() -> String {
router().get()
}
pub fn navigate_to(route: &str) {
router().set(route.to_string());
}
pub fn query_state<Msg>(ui: &UITree<Msg>) -> AgentState {
let mut interactive = Vec::new();
let mut data = Vec::new();
walk(ui, &mut interactive, &mut data);
AgentState {
interactive_elements: interactive,
data_elements: data,
current_route: current_route(),
}
}
fn walk<Msg>(
node: &UITree<Msg>,
interactive: &mut Vec<ElementSummary>,
data: &mut Vec<ElementSummary>,
) {
let id = node.meta.data_appfront_id;
let ai = &node.meta.ai;
match &node.kind {
NodeKind::Button { label } => {
interactive.push(ElementSummary {
id,
kind: "button".into(),
label: Some(label.clone()),
value: None,
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Input { value } => {
interactive.push(ElementSummary {
id,
kind: "input".into(),
label: None,
value: Some(value.clone()),
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Textarea { value } => {
interactive.push(ElementSummary {
id,
kind: "textarea".into(),
label: None,
value: Some(value.clone()),
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Checkbox { label, checked } => {
interactive.push(ElementSummary {
id,
kind: "checkbox".into(),
label: Some(label.clone()),
value: Some(checked.to_string()),
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Select { selected, .. } => {
interactive.push(ElementSummary {
id,
kind: "select".into(),
label: None,
value: Some(selected.clone()),
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Radio { selected, .. } => {
interactive.push(ElementSummary {
id,
kind: "radio".into(),
label: None,
value: Some(selected.clone()),
action: ai.action.clone(),
params: ai.params.clone(),
description: ai.description.clone(),
});
}
NodeKind::Heading { level, text } => {
data.push(ElementSummary {
id,
kind: format!("h{level}"),
label: Some(text.clone()),
value: None,
action: None,
params: Vec::new(),
description: None,
});
}
NodeKind::Text { text } => {
data.push(ElementSummary {
id,
kind: "text".into(),
label: Some(text.clone()),
value: None,
action: None,
params: Vec::new(),
description: None,
});
}
NodeKind::Container { children } => {
for child in children {
walk(child, interactive, data);
}
}
NodeKind::List { items } => {
for item in items {
walk(item, interactive, data);
}
}
NodeKind::DataGrid { columns, rows } => {
data.push(ElementSummary {
id,
kind: "data_grid".into(),
label: Some(format!("[{}] — {} rows", columns.join(", "), rows.len())),
value: None,
action: None,
params: Vec::new(),
description: None,
});
}
NodeKind::Portal { content, .. } => {
walk(content, interactive, data);
}
}
}
pub fn trigger_event<Msg>(ui: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
where
Msg: Clone,
{
find_and_dispatch(ui, action, dispatch)
}
fn find_and_dispatch<Msg>(node: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
where
Msg: Clone,
{
if node.meta.ai.action.as_deref() == Some(action) {
if let Some(msg) = &node.meta.on_click {
dispatch(msg.clone());
return true;
}
}
match &node.kind {
NodeKind::Container { children } => {
for child in children {
if find_and_dispatch(child, action, dispatch) {
return true;
}
}
}
NodeKind::List { items } => {
for item in items {
if find_and_dispatch(item, action, dispatch) {
return true;
}
}
}
_ => {}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq)]
enum TestMsg {
Submit,
}
fn sample_ui() -> UITree<TestMsg> {
UITree::container(|c| {
c.heading(1, "Dashboard").class("text-2xl font-bold");
c.button("Export")
.on_click(TestMsg::Submit)
.ai_action("export_data")
.ai_param("format", "csv")
.ai_description("Export the data as CSV");
c.input("hello")
.ai_action("search")
.ai_param("key", "query");
c.list(|l| {
l.text("Item A");
l.text("Item B");
});
c.data_grid(["A", "B"], [vec!["1", "2"], vec!["3", "4"]]);
})
}
#[test]
fn query_state_collects_interactive_and_data() {
let ui = sample_ui();
let state = query_state(&ui);
assert_eq!(state.interactive_elements.len(), 2);
let btn = &state.interactive_elements[0];
assert_eq!(btn.kind, "button");
assert_eq!(btn.label.as_deref(), Some("Export"));
assert_eq!(btn.action.as_deref(), Some("export_data"));
assert_eq!(btn.params, vec![("format".into(), "csv".into())]);
assert_eq!(btn.description.as_deref(), Some("Export the data as CSV"));
let input = &state.interactive_elements[1];
assert_eq!(input.kind, "input");
assert_eq!(input.value.as_deref(), Some("hello"));
assert_eq!(input.action.as_deref(), Some("search"));
assert_eq!(state.data_elements.len(), 4);
assert_eq!(state.data_elements[0].kind, "h1");
assert_eq!(state.data_elements[0].label.as_deref(), Some("Dashboard"));
assert_eq!(state.data_elements[1].kind, "text");
assert_eq!(state.data_elements[1].label.as_deref(), Some("Item A"));
assert_eq!(state.data_elements[2].kind, "text");
assert_eq!(state.data_elements[2].label.as_deref(), Some("Item B"));
assert_eq!(state.data_elements[3].kind, "data_grid");
assert_eq!(
state.data_elements[3].label.as_deref(),
Some("[A, B] — 2 rows")
);
assert_eq!(state.current_route, "/");
}
#[test]
fn trigger_event_dispatches_matching_action() {
let mut ui = sample_ui();
ui.assign_ids();
let dispatched = std::cell::Cell::new(None::<TestMsg>);
let dispatch = |msg: TestMsg| {
dispatched.set(Some(msg));
};
let result = trigger_event(&ui, "export_data", &dispatch);
assert!(result, "should find and dispatch the event");
assert_eq!(dispatched.take(), Some(TestMsg::Submit));
}
#[test]
fn trigger_event_returns_false_for_unknown_action() {
let ui = sample_ui();
let result = trigger_event(&ui, "nonexistent", &|_: TestMsg| {});
assert!(!result, "unknown action should return false");
}
#[test]
fn trigger_event_returns_false_when_no_on_click() {
let ui = sample_ui();
let result = trigger_event(&ui, "search", &|_: TestMsg| {});
assert!(!result, "action without on_click should return false");
}
#[test]
fn navigate_updates_route() {
let before = current_route();
assert_eq!(before, "/", "default route is /");
navigate_to("/dashboard");
assert_eq!(current_route(), "/dashboard");
navigate_to("/settings");
assert_eq!(current_route(), "/settings");
}
#[test]
fn route_signal_is_reactive() {
use crate::signal::create_effect;
use std::rc::Rc;
let seen = Rc::new(std::cell::RefCell::new(Vec::new()));
let route = route_signal();
let seen_clone = Rc::clone(&seen);
let _handle = create_effect(move || {
let r = route.get();
seen_clone.borrow_mut().push(r);
});
assert_eq!(seen.borrow().len(), 1);
navigate_to("/foo");
assert_eq!(seen.borrow().len(), 2);
assert_eq!(seen.borrow()[1], "/foo");
}
#[test]
fn query_state_respects_assign_ids() {
let mut ui = sample_ui();
ui.assign_ids();
let state = query_state(&ui);
for el in &state.interactive_elements {
assert!(el.id.is_some(), "interactive element should have id");
}
for el in &state.data_elements {
assert!(el.id.is_some(), "data element should have id");
}
assert_eq!(state.interactive_elements[0].id, Some(3));
}
#[test]
fn query_state_flat_list_and_container() {
let mut ui: UITree<TestMsg> = UITree::container(|c| {
c.container(|inner| {
inner.button("Nested").ai_action("nested_btn");
});
c.list(|l| {
l.button("List button").ai_action("list_btn");
});
});
ui.assign_ids();
let state = query_state(&ui);
assert_eq!(state.interactive_elements.len(), 2);
assert_eq!(
state.interactive_elements[0].action.as_deref(),
Some("nested_btn")
);
assert_eq!(
state.interactive_elements[1].action.as_deref(),
Some("list_btn")
);
}
}