1use 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 Textarea { value: String },
27 Checkbox { label: String, checked: bool },
31 Select {
35 options: Vec<(String, String)>,
36 selected: String,
37 },
38 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 Portal {
61 target: String,
62 content: Box<UITree<Msg>>,
63 },
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct AiMeta {
69 pub action: Option<String>,
72 pub params: Vec<(String, String)>,
74 pub description: Option<String>,
76}
77
78pub type OnInput<Msg> = std::sync::Arc<dyn Fn(String) -> Msg + Send + Sync>;
88
89pub type OnToggle<Msg> = std::sync::Arc<dyn Fn(bool) -> Msg + Send + Sync>;
93
94fn on_input_default<Msg>() -> Option<OnInput<Msg>> {
100 None
101}
102
103fn 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 #[serde(skip, default = "on_input_default")]
119 pub on_input: Option<OnInput<Msg>>,
120 #[serde(skip, default = "on_toggle_default")]
123 pub on_toggle: Option<OnToggle<Msg>>,
124 pub ai: AiMeta,
125 pub data_appfront_id: Option<u64>,
128 #[serde(default)]
136 pub is_dynamic: bool,
137 #[serde(default)]
144 pub attrs: Vec<(String, String)>,
145 #[serde(default)]
150 pub key: Option<String>,
151 #[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
174impl<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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct HydrationPayload<Msg> {
323 pub tree: UITree<Msg>,
325 pub signals: std::collections::HashMap<String, serde_json::Value>,
327}
328
329pub struct ContainerBuilder<Msg> {
333 children: Vec<UITree<Msg>>,
334}
335
336impl<Msg> ContainerBuilder<Msg> {
337 pub fn new() -> Self {
341 ContainerBuilder {
342 children: Vec::new(),
343 }
344 }
345
346 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 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 pub fn textarea(&mut self, value: impl Into<String>) -> NodeRef<'_, Msg> {
415 self.push(NodeKind::Textarea {
416 value: value.into(),
417 })
418 }
419
420 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 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 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 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
514pub 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 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 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 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 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 pub fn key(mut self, key: impl Into<String>) -> Self {
586 self.meta_mut().key = Some(key.into());
587 self
588 }
589
590 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 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 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}