Skip to main content

tpt_appfront_core/
ui_tree.rs

1//! The abstract `UITree` AST (see `spec.txt` section 3.1).
2//!
3//! Every node carries a type-specific [`NodeKind`] plus shared [`NodeMeta`]
4//! (styling class, event bindings, AI metadata). `Msg` is the application's
5//! own event enum — e.g. `on_click(Event::ExportData)` in the spec's
6//! example — so the core crate never needs to know what events an app defines.
7
8use serde::{Deserialize, Serialize};
9
10use crate::virtual_scroll::VirtualScroll;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct UITree<Msg> {
14    pub kind: NodeKind<Msg>,
15    pub meta: NodeMeta<Msg>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum NodeKind<Msg> {
20    Container { children: Vec<UITree<Msg>> },
21    Heading { level: u8, text: String },
22    Text { text: String },
23    Button { label: String },
24    Input { value: String },
25    /// Multi-line text input.
26    Textarea { value: String },
27    /// A single boolean toggle, e.g. `<input type="checkbox">`. Two-way bound
28    /// via [`NodeMeta::on_toggle`] rather than [`NodeMeta::on_input`] since its
29    /// value is a `bool`, not a `String`.
30    Checkbox { label: String, checked: bool },
31    /// A single-choice dropdown. `options` is `(value, label)` pairs;
32    /// `selected` is the currently-chosen option's `value`. Two-way bound via
33    /// [`NodeMeta::on_input`] (the new selected value).
34    Select {
35        options: Vec<(String, String)>,
36        selected: String,
37    },
38    /// A single-choice radio button group. `name` groups the individual radio
39    /// inputs so selecting one clears the others — required by HTML's radio
40    /// semantics and mirrored by non-DOM backends for consistency. `options`
41    /// is `(value, label)` pairs; `selected` is the currently-chosen value.
42    /// Two-way bound via [`NodeMeta::on_input`].
43    Radio {
44        name: String,
45        options: Vec<(String, String)>,
46        selected: String,
47    },
48    List { items: Vec<UITree<Msg>> },
49    DataGrid {
50        columns: Vec<String>,
51        rows: Vec<Vec<String>>,
52    },
53    /// A "portal": its `content` is rendered not inline at this node's position
54    /// but into the named portal *target* instead. Hosts (DOM/canvas/TUI)
55    /// render portal targets as overlay layers (modal/toast/tooltip surfaces)
56    /// regardless of where in the logical tree the portal was declared. See
57    /// [`UITree::collect_portals`] and [`ContainerBuilder::portal`]. Backend-
58    /// agnostic: a backend that doesn't support portal targets can simply
59    /// inline `content` at the declaration site as a fallback.
60    Portal {
61        target: String,
62        content: Box<UITree<Msg>>,
63    },
64}
65
66/// AI-agent metadata attached to any node (see `docs/ai-schema.md`).
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct AiMeta {
69    /// Machine-readable action name (e.g. `"add_to_cart"`). When set, the
70    /// node is considered an interactive action that an AI agent can invoke.
71    pub action: Option<String>,
72    /// Key-value parameter map the action expects.
73    pub params: Vec<(String, String)>,
74    /// Human-readable description of what this element does.
75    pub description: Option<String>,
76}
77
78/// Two-way-binding callback for string-valued form nodes (`Input`,
79/// `Textarea`, `Select`, `Radio`): takes the control's new string value
80/// (known only once the change event fires) and produces a `Msg` to
81/// dispatch, mirroring `on_click`'s dispatch pattern but parameterized by a
82/// runtime value instead of a value baked in at tree-build time. `Arc<dyn Fn
83/// + Send + Sync>` (not `Rc`) — same reasoning as
84/// `tpt_appfront_server::router::CommandHandler`: a `UITree` can end up behind
85/// an `Arc<SmartRouter<Msg>>` shared across an Axum server's worker threads,
86/// which requires every field to be `Send + Sync`.
87pub type OnInput<Msg> = std::sync::Arc<dyn Fn(String) -> Msg + Send + Sync>;
88
89/// Two-way-binding callback for `Checkbox` nodes: takes the checkbox's new
90/// `checked` state and produces a `Msg` to dispatch. Separate from
91/// [`OnInput`] since a checkbox's value is a `bool`, not a `String`.
92pub type OnToggle<Msg> = std::sync::Arc<dyn Fn(bool) -> Msg + Send + Sync>;
93
94/// `#[serde(default = "...")]` target for [`NodeMeta::on_input`]. Needed
95/// because plain `#[serde(skip)]` makes serde's derive require `Msg:
96/// Default` (it infers the bound from the field's generic parameters, not
97/// realizing `Option<T>: Default` doesn't actually need `T: Default`) —
98/// spelling out the default function sidesteps that overly-strict inference.
99fn on_input_default<Msg>() -> Option<OnInput<Msg>> {
100    None
101}
102
103/// `#[serde(default = "...")]` target for [`NodeMeta::on_toggle`] — see
104/// [`on_input_default`] for why this can't just be `#[serde(skip)]`.
105fn on_toggle_default<Msg>() -> Option<OnToggle<Msg>> {
106    None
107}
108
109#[derive(Clone, Serialize, Deserialize)]
110pub struct NodeMeta<Msg> {
111    pub class: Option<String>,
112    pub on_click: Option<Msg>,
113    /// See [`OnInput`]. Not serializable — a live closure can't survive
114    /// SSR/hydration JSON, and SSR/AI-schema rendering never needs to *call*
115    /// it, only know an input exists. Currently only `tpt-appfront-dom` wires
116    /// this into a real `oninput` listener; `tpt-appfront-canvas`/`tpt-appfront-tui`
117    /// don't consume it yet.
118    #[serde(skip, default = "on_input_default")]
119    pub on_input: Option<OnInput<Msg>>,
120    /// See [`OnToggle`]. Two-way binding for `Checkbox` nodes; not
121    /// serializable, same reasoning as `on_input`.
122    #[serde(skip, default = "on_toggle_default")]
123    pub on_toggle: Option<OnToggle<Msg>>,
124    pub ai: AiMeta,
125    /// Stable identifier assigned before SSR so the client hydrator can match
126    /// server-rendered DOM nodes back to their `UITree` counterpart.
127    pub data_appfront_id: Option<u64>,
128    /// Whether the subtree this node roots was produced by a
129    /// `#[tpt_appfront_core::component]` function whose body reads any
130    /// `Signal`. `false` (the default) means either the node wasn't
131    /// produced by the macro, or the macro's static analysis found no
132    /// signal reads in the function body. Backends can use this as a hint
133    /// to skip hydration/listener work for subtrees that never change
134    /// (see Phase 9 islands hydration).
135    #[serde(default)]
136    pub is_dynamic: bool,
137    /// Arbitrary key/value attributes rendered verbatim by backends. Used for
138    /// accessibility (`role`, `aria-*`, `tabindex`), semantic landmarks
139    /// (`aria-label`, `role="navigation"`), and any backend-specific attribute
140    /// that has no first-class `NodeMeta` field. Serialized as-is, so values
141    /// must be `String` (no closures). Backends render these as DOM attributes
142    /// (HTML/SSR), and ignore the ones they don't model (canvas/TUI).
143    #[serde(default)]
144    pub attrs: Vec<(String, String)>,
145    /// Stable identity for reconciliation, e.g. a row/entity id. Set via
146    /// [`NodeRef::key`] on items inside a [`NodeKind::List`]/[`NodeKind::DataGrid`]
147    /// so backends can diff add/remove/reorder against a previous render
148    /// instead of rebuilding every child from scratch.
149    #[serde(default)]
150    pub key: Option<String>,
151    /// Windowed-rendering config for `List`/`DataGrid` nodes — see
152    /// [`VirtualScroll`]. `None` (the default) means render every item.
153    #[serde(default)]
154    pub virtual_scroll: Option<VirtualScroll>,
155}
156
157impl<Msg> Default for NodeMeta<Msg> {
158    fn default() -> Self {
159        NodeMeta {
160            class: None,
161            on_click: None,
162            on_input: None,
163            on_toggle: None,
164            ai: AiMeta::default(),
165            data_appfront_id: None,
166            is_dynamic: false,
167            attrs: Vec::new(),
168            key: None,
169            virtual_scroll: None,
170        }
171    }
172}
173
174/// Manual impl since `on_input`'s `Rc<dyn Fn(String) -> Msg>` can't derive
175/// `Debug` (trait objects for `Fn` don't implement it) — every other field
176/// still prints normally, `on_input` prints as a presence marker.
177impl<Msg: std::fmt::Debug> std::fmt::Debug for NodeMeta<Msg> {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("NodeMeta")
180            .field("class", &self.class)
181            .field("on_click", &self.on_click)
182            .field("on_input", &self.on_input.as_ref().map(|_| "<fn>"))
183            .field("on_toggle", &self.on_toggle.as_ref().map(|_| "<fn>"))
184            .field("ai", &self.ai)
185            .field("data_appfront_id", &self.data_appfront_id)
186            .field("is_dynamic", &self.is_dynamic)
187            .field("key", &self.key)
188            .field("virtual_scroll", &self.virtual_scroll)
189            .finish()
190    }
191}
192
193impl<Msg> UITree<Msg> {
194    fn leaf(kind: NodeKind<Msg>) -> Self {
195        UITree {
196            kind,
197            meta: NodeMeta::default(),
198        }
199    }
200
201    /// Builds a `Container` node from a closure, mirroring the spec's
202    /// `UITree::container(|c| { ... })` ergonomics.
203    pub fn container(build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> Self {
204        let mut builder = ContainerBuilder { children: Vec::new() };
205        build(&mut builder);
206        UITree::leaf(NodeKind::Container {
207            children: builder.children,
208        })
209    }
210
211    pub fn meta_mut(&mut self) -> &mut NodeMeta<Msg> {
212        &mut self.meta
213    }
214
215    /// Collects the contents of every portal targeting `target` anywhere in
216    /// this tree (including nested portals), in document order. Hosts render
217    /// these as an overlay layer independent of where each portal was declared.
218    /// Portals that target a *different* name are ignored (but still walked, so
219    /// nested portals are found wherever they live).
220    pub fn collect_portals(&self, target: &str) -> Vec<UITree<Msg>>
221    where
222        Msg: Clone,
223    {
224        fn walk<Msg: Clone>(ui: &UITree<Msg>, target: &str, out: &mut Vec<UITree<Msg>>) {
225            match &ui.kind {
226                NodeKind::Container { children } => {
227                    for child in children {
228                        walk(child, target, out);
229                    }
230                }
231                NodeKind::List { items } => {
232                    for item in items {
233                        walk(item, target, out);
234                    }
235                }
236                NodeKind::Portal {
237                    target: t,
238                    content,
239                } => {
240                    if t == target {
241                        out.push((**content).clone());
242                    } else {
243                        // Still recurse: a nested portal may target `target`.
244                        walk(content, target, out);
245                    }
246                }
247                _ => {}
248            }
249        }
250        let mut out = Vec::new();
251        walk(self, target, &mut out);
252        out
253    }
254
255    /// Names of all distinct portal targets referenced anywhere in this tree.
256    /// Useful for a host to pre-create its overlay layers.
257    pub fn portal_targets(&self) -> std::collections::BTreeSet<String> {
258        fn walk<Msg>(ui: &UITree<Msg>, out: &mut std::collections::BTreeSet<String>) {
259            match &ui.kind {
260                NodeKind::Container { children } => {
261                    for child in children {
262                        walk(child, out);
263                    }
264                }
265                NodeKind::List { items } => {
266                    for item in items {
267                        walk(item, out);
268                    }
269                }
270                NodeKind::Portal { target, content } => {
271                    out.insert(target.clone());
272                    walk(content, out);
273                }
274                _ => {}
275            }
276        }
277        let mut out = std::collections::BTreeSet::new();
278        walk(self, &mut out);
279        out
280    }
281
282    /// Walks the tree and assigns a unique sequential [`NodeMeta::data_appfront_id`]
283    /// to every node. Safe to call multiple times — previously assigned IDs are
284    /// overwritten.
285    pub fn assign_ids(&mut self) {
286        fn walk<Msg>(ui: &mut UITree<Msg>, next: &mut u64) {
287            ui.meta.data_appfront_id = Some(*next);
288            *next += 1;
289            match &mut ui.kind {
290                NodeKind::Container { children } => {
291                    for child in children {
292                        walk(child, next);
293                    }
294                }
295                NodeKind::List { items } => {
296                    for item in items {
297                        walk(item, next);
298                    }
299                }
300                NodeKind::Portal { content, .. } => {
301                    walk(content, next);
302                }
303                NodeKind::DataGrid { .. }
304                | NodeKind::Heading { .. }
305                | NodeKind::Text { .. }
306                | NodeKind::Button { .. }
307                | NodeKind::Input { .. }
308                | NodeKind::Textarea { .. }
309                | NodeKind::Checkbox { .. }
310                | NodeKind::Select { .. }
311                | NodeKind::Radio { .. } => {}
312            }
313        }
314        walk(self, &mut 1);
315    }
316}
317
318/// Payload serialised into `<script id="__APPFRONT_STATE__">` during SSR and
319/// consumed by [`hydrate`][crate::dom::hydrate] on the client to resume
320/// interactivity without re-creating DOM nodes.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct HydrationPayload<Msg> {
323    /// The full tree (with `data_appfront_id` filled).
324    pub tree: UITree<Msg>,
325    /// Named signal values that the client should restore before effects fire.
326    pub signals: std::collections::HashMap<String, serde_json::Value>,
327}
328
329/// Passed into the closure given to [`UITree::container`]; each method
330/// appends a child node and returns a [`NodeRef`] so callers can chain
331/// `.class(...)` / `.on_click(...)` onto the node they just added.
332pub struct ContainerBuilder<Msg> {
333    children: Vec<UITree<Msg>>,
334}
335
336impl<Msg> ContainerBuilder<Msg> {
337    /// Creates an empty builder. Used by macro codegen for static-subtree
338    /// caching (the `view!`/`#[component]` `static_tree` path), which builds
339    /// a one-off subtree and extracts it via [`ContainerBuilder::into_only_child`].
340    pub fn new() -> Self {
341        ContainerBuilder {
342            children: Vec::new(),
343        }
344    }
345
346    /// Consumes the builder and returns its single child (the result of a
347    /// macro-generated subtree built via [`ContainerBuilder::new`]). Panics if
348    /// the builder produced zero or more than one child, since the static-
349    /// subtree codegen always builds exactly one root node.
350    pub fn into_only_child(self) -> Option<UITree<Msg>> {
351        if self.children.len() == 1 {
352            Some(self.children.into_iter().next().unwrap())
353        } else {
354            None
355        }
356    }
357
358    fn push(&mut self, kind: NodeKind<Msg>) -> NodeRef<'_, Msg> {
359        self.children.push(UITree::leaf(kind));
360        let index = self.children.len() - 1;
361        NodeRef {
362            children: &mut self.children,
363            index,
364        }
365    }
366
367    pub fn container(&mut self, build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> NodeRef<'_, Msg> {
368        let node = UITree::container(build);
369        self.children.push(node);
370        let index = self.children.len() - 1;
371        NodeRef {
372            children: &mut self.children,
373            index,
374        }
375    }
376
377    /// Appends an already-built `UITree<Msg>` as a child and returns a
378    /// [`NodeRef`] to it. Primarily used by the `view!` macro's static-subtree
379    /// codegen, which hands a [`crate::static_tree::static_node`] instance
380    /// here instead of rebuilding the subtree inline every render.
381    pub fn with(&mut self, node: UITree<Msg>) -> NodeRef<'_, Msg> {
382        self.children.push(node);
383        let index = self.children.len() - 1;
384        NodeRef {
385            children: &mut self.children,
386            index,
387        }
388    }
389
390    pub fn heading(&mut self, level: u8, text: impl Into<String>) -> NodeRef<'_, Msg> {
391        self.push(NodeKind::Heading {
392            level,
393            text: text.into(),
394        })
395    }
396
397    pub fn text(&mut self, text: impl Into<String>) -> NodeRef<'_, Msg> {
398        self.push(NodeKind::Text { text: text.into() })
399    }
400
401    pub fn button(&mut self, label: impl Into<String>) -> NodeRef<'_, Msg> {
402        self.push(NodeKind::Button {
403            label: label.into(),
404        })
405    }
406
407    pub fn input(&mut self, value: impl Into<String>) -> NodeRef<'_, Msg> {
408        self.push(NodeKind::Input {
409            value: value.into(),
410        })
411    }
412
413    /// A multi-line text input. Two-way bound via [`NodeRef::on_input`].
414    pub fn textarea(&mut self, value: impl Into<String>) -> NodeRef<'_, Msg> {
415        self.push(NodeKind::Textarea {
416            value: value.into(),
417        })
418    }
419
420    /// A boolean toggle. Two-way bound via [`NodeRef::on_toggle`].
421    pub fn checkbox(&mut self, label: impl Into<String>, checked: bool) -> NodeRef<'_, Msg> {
422        self.push(NodeKind::Checkbox {
423            label: label.into(),
424            checked,
425        })
426    }
427
428    /// A single-choice dropdown. `options` is `(value, label)` pairs.
429    /// Two-way bound via [`NodeRef::on_input`] (the newly selected value).
430    pub fn select(
431        &mut self,
432        options: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
433        selected: impl Into<String>,
434    ) -> NodeRef<'_, Msg> {
435        self.push(NodeKind::Select {
436            options: options
437                .into_iter()
438                .map(|(v, l)| (v.into(), l.into()))
439                .collect(),
440            selected: selected.into(),
441        })
442    }
443
444    /// A single-choice radio button group sharing `name`. `options` is
445    /// `(value, label)` pairs. Two-way bound via [`NodeRef::on_input`] (the
446    /// newly selected value).
447    pub fn radio_group(
448        &mut self,
449        name: impl Into<String>,
450        options: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
451        selected: impl Into<String>,
452    ) -> NodeRef<'_, Msg> {
453        self.push(NodeKind::Radio {
454            name: name.into(),
455            options: options
456                .into_iter()
457                .map(|(v, l)| (v.into(), l.into()))
458                .collect(),
459            selected: selected.into(),
460        })
461    }
462
463    pub fn list(&mut self, build: impl FnOnce(&mut ContainerBuilder<Msg>)) -> NodeRef<'_, Msg> {
464        let mut inner = ContainerBuilder { children: Vec::new() };
465        build(&mut inner);
466        self.push(NodeKind::List {
467            items: inner.children,
468        })
469    }
470
471    /// Declares a portal: `build` produces a subtree that is rendered into the
472    /// named portal *target* (an overlay layer) rather than inline. The host
473    /// collects portals via [`UITree::collect_portals`] and renders each target
474    /// independently — this is how modals/toasts/tooltips escape their logical
475    /// parent's clipping/stacking context. Returns a [`NodeRef`] to the portal
476    /// node (the declaration site) for chaining `class`/`key` onto it.
477    pub fn portal(
478        &mut self,
479        target: impl Into<String>,
480        build: impl FnOnce(&mut ContainerBuilder<Msg>),
481    ) -> NodeRef<'_, Msg> {
482        let mut inner = ContainerBuilder { children: Vec::new() };
483        build(&mut inner);
484        let single = inner.into_only_child().unwrap_or_else(|| {
485            UITree::container(|_| {})
486        });
487        self.push(NodeKind::Portal {
488            target: target.into(),
489            content: Box::new(single),
490        })
491    }
492
493    pub fn data_grid(
494        &mut self,
495        columns: impl IntoIterator<Item = impl Into<String>>,
496        rows: impl IntoIterator<Item = impl IntoIterator<Item = impl Into<String>>>,
497    ) -> NodeRef<'_, Msg> {
498        self.push(NodeKind::DataGrid {
499            columns: columns.into_iter().map(Into::into).collect(),
500            rows: rows
501                .into_iter()
502                .map(|row| row.into_iter().map(Into::into).collect())
503                .collect(),
504        })
505    }
506}
507
508impl<Msg> Default for ContainerBuilder<Msg> {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514/// A chainable reference to the node most recently pushed onto a
515/// [`ContainerBuilder`], used to set styling/events without needing a
516/// separate variable per node.
517pub struct NodeRef<'a, Msg> {
518    children: &'a mut Vec<UITree<Msg>>,
519    index: usize,
520}
521
522impl<'a, Msg> NodeRef<'a, Msg> {
523    fn meta_mut(&mut self) -> &mut NodeMeta<Msg> {
524        self.children[self.index].meta_mut()
525    }
526
527    pub fn class(mut self, class: impl Into<String>) -> Self {
528        self.meta_mut().class = Some(class.into());
529        self
530    }
531
532    pub fn on_click(mut self, msg: Msg) -> Self {
533        self.meta_mut().on_click = Some(msg);
534        self
535    }
536
537    /// Two-way binding for `Input` nodes: `f` is called with the input's new
538    /// value on every change, producing a `Msg` to dispatch. See
539    /// [`OnInput`]. Currently only wired up by `tpt-appfront-dom`.
540    pub fn on_input(mut self, f: impl Fn(String) -> Msg + Send + Sync + 'static) -> Self {
541        self.meta_mut().on_input = Some(std::sync::Arc::new(f));
542        self
543    }
544
545    /// Two-way binding for `Checkbox` nodes: `f` is called with the
546    /// checkbox's new `checked` state on every change. See [`OnToggle`].
547    pub fn on_toggle(mut self, f: impl Fn(bool) -> Msg + Send + Sync + 'static) -> Self {
548        self.meta_mut().on_toggle = Some(std::sync::Arc::new(f));
549        self
550    }
551
552    pub fn ai_action(mut self, action: impl Into<String>) -> Self {
553        self.meta_mut().ai.action = Some(action.into());
554        self
555    }
556
557    pub fn ai_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
558        self.meta_mut().ai.params.push((key.into(), value.into()));
559        self
560    }
561
562    pub fn ai_description(mut self, desc: impl Into<String>) -> Self {
563        self.meta_mut().ai.description = Some(desc.into());
564        self
565    }
566
567    /// Sets an arbitrary attribute (e.g. `role`, `tabindex`, `aria-*`,
568    /// `placeholder`). Rendered verbatim by backends that model HTML
569    /// attributes; ignored where unsupported. See [`NodeMeta::attrs`].
570    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
571        self.meta_mut().attrs.push((name.into(), value.into()));
572        self
573    }
574
575    /// Convenience for ARIA attributes (e.g. `self.aria("label", "menu")`
576    /// emits `aria-label="menu"`).
577    pub fn aria(self, name: impl Into<String>, value: impl Into<String>) -> Self {
578        let mut full = String::from("aria-");
579        full.push_str(&name.into());
580        self.attr(full, value)
581    }
582
583    /// Stable identity for reconciliation (e.g. a row/entity id) — see
584    /// [`NodeMeta::key`].
585    pub fn key(mut self, key: impl Into<String>) -> Self {
586        self.meta_mut().key = Some(key.into());
587        self
588    }
589
590    /// Enables windowed rendering for a `List`/`DataGrid` — see
591    /// [`VirtualScroll`].
592    pub fn virtual_scroll(mut self, config: VirtualScroll) -> Self {
593        self.meta_mut().virtual_scroll = Some(config);
594        self
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
603    enum Event {
604        ExportData,
605    }
606
607    fn sample_ui() -> UITree<Event> {
608        UITree::container(|c| {
609            c.heading(1, "Dashboard").class("text-2xl font-bold");
610            c.data_grid(["Name", "Value"], [vec!["a", "1"], vec!["b", "2"]])
611                .class("w-full mt-4");
612            c.button("Export").on_click(Event::ExportData);
613        })
614    }
615
616    #[test]
617    fn builder_produces_expected_shape() {
618        let ui = sample_ui();
619        let NodeKind::Container { children } = ui.kind else {
620            panic!("expected container");
621        };
622        assert_eq!(children.len(), 3);
623
624        match &children[0].kind {
625            NodeKind::Heading { level, text } => {
626                assert_eq!(*level, 1);
627                assert_eq!(text, "Dashboard");
628            }
629            _ => panic!("expected heading"),
630        }
631        assert_eq!(
632            children[0].meta.class.as_deref(),
633            Some("text-2xl font-bold")
634        );
635
636        match &children[1].kind {
637            NodeKind::DataGrid { columns, rows } => {
638                assert_eq!(columns, &["Name", "Value"]);
639                assert_eq!(rows.len(), 2);
640            }
641            _ => panic!("expected data grid"),
642        }
643
644        match &children[2].kind {
645            NodeKind::Button { label } => assert_eq!(label, "Export"),
646            _ => panic!("expected button"),
647        }
648        assert_eq!(children[2].meta.on_click, Some(Event::ExportData));
649    }
650
651    #[test]
652    fn round_trips_through_json() {
653        let ui = sample_ui();
654        let json = serde_json::to_string(&ui).expect("serialize");
655        let restored: UITree<Event> = serde_json::from_str(&json).expect("deserialize");
656        assert_eq!(
657            format!("{restored:?}"),
658            format!("{:?}", ui),
659            "round-tripped tree should match the original"
660        );
661    }
662
663    #[test]
664    fn assign_ids_assigns_sequential_ids() {
665        let mut ui = UITree::container(|c| {
666            c.heading(2, "Section");
667            c.list(|l| {
668                l.text("item");
669            });
670            c.container(|inner| {
671                inner.button("Go").on_click(Event::ExportData);
672            });
673        });
674
675        ui.assign_ids();
676
677        // Container root = 1
678        assert_eq!(ui.meta.data_appfront_id, Some(1));
679
680        let NodeKind::Container { children } = &ui.kind else {
681            panic!("expected container");
682        };
683
684        // heading = 2, list = 3, nested container = 5
685        assert_eq!(children[0].meta.data_appfront_id, Some(2));
686        assert_eq!(children[1].meta.data_appfront_id, Some(3));
687
688        let NodeKind::List { items } = &children[1].kind else {
689            panic!("expected list");
690        };
691        assert_eq!(items[0].meta.data_appfront_id, Some(4));
692
693        assert_eq!(children[2].meta.data_appfront_id, Some(5));
694
695        let NodeKind::Container { children: inner_children } = &children[2].kind else {
696            panic!("expected container");
697        };
698        assert_eq!(inner_children[0].meta.data_appfront_id, Some(6));
699    }
700
701    #[test]
702    fn hydration_payload_round_trips() {
703        let mut ui = sample_ui();
704        ui.assign_ids();
705
706        let mut signals = std::collections::HashMap::new();
707        signals.insert("count".to_string(), serde_json::json!(42));
708
709        let payload = HydrationPayload {
710            tree: ui,
711            signals: signals.clone(),
712        };
713
714        let json = serde_json::to_string(&payload).expect("serialize");
715        let restored: HydrationPayload<Event> =
716            serde_json::from_str(&json).expect("deserialize");
717
718        assert_eq!(restored.tree.meta.data_appfront_id, Some(1));
719        assert_eq!(restored.signals.get("count"), signals.get("count"));
720    }
721}