1mod containers;
19pub mod fields;
20mod misc;
21mod overlays;
22pub use overlays::is_context_menu;
23pub mod search;
24mod table;
25
26use std::collections::HashMap;
27
28use indexmap::IndexMap;
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Map, Value};
31
32use crate::catalog::CatalogView;
33use crate::data::{resolve_value, ResolveContext};
34use crate::list::{ListWindow, VisibleRange};
35use crate::overlay::{OverlayAlign, OverlaySide};
36use crate::recipes::RecipeIndex;
37use crate::strings::StringTable;
38use crate::theme::RecipeQuery;
39use crate::types::{Props, UiNode};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum NodeKind {
44 Container,
45 Leaf,
46}
47
48#[derive(Debug, Clone, PartialEq)]
51pub struct LNode {
52 pub index: u32,
55 pub id: String,
56 pub component: String,
58 pub part: Option<String>,
60 pub owner: Option<String>,
62 pub owner_component: Option<String>,
63 pub catalog_id: Option<String>,
65 pub extension_kind: Option<String>,
66 pub depth: u32,
67 pub parent: Option<u32>,
68 pub children: Vec<u32>,
69 pub layer: u32,
70 pub kind: NodeKind,
71 pub props: Props,
73 pub base_style: Props,
76 pub default_keys: Vec<String>,
80 pub own_query: Option<RecipeQuery>,
82 pub part_query: Option<RecipeQuery>,
84 pub authored_keys: Vec<String>,
87 pub hidden: bool,
88 pub lines: Option<u32>,
89 pub pressable: bool,
90 pub scope: String,
92 pub source: Option<SourceRef>,
97 pub on: Option<IndexMap<String, Value>>,
99 pub accessibility: Option<Value>,
100 pub trigger_for: Option<String>,
102 pub form: Option<String>,
104 pub form_disabled: bool,
108 pub live: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SourceRef {
116 pub id: String,
117 pub template: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub enum LayerPlacement {
123 Centered,
125 Edge(OverlaySide),
127 Anchored { anchor: u32, side: OverlaySide, align: OverlayAlign },
129 AtPoint { x: f32, y: f32 },
131 Toast { order: u32 },
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "lowercase")]
139pub enum LayerClass {
140 Overlay,
141 Toast,
142}
143
144#[derive(Debug, Clone, PartialEq)]
145pub struct LayerSpec {
146 pub layer: u32,
147 pub root: u32,
148 pub kind: String,
149 pub owner: String,
150 pub placement: LayerPlacement,
151 pub class: LayerClass,
152 pub modal: bool,
154 pub dismissible: bool,
156 pub match_anchor_width: bool,
158 pub scroll_body: Option<u32>,
161 pub mirror: bool,
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct ListSpec {
169 pub node: u32,
170 pub id: String,
172 pub keys: std::sync::Arc<Vec<String>>,
173 pub range: VisibleRange,
174 pub offsets: std::sync::Arc<Vec<f32>>,
175 pub content_height: f32,
178 pub windowed: bool,
179 pub horizontal: bool,
181 pub gap: f32,
183 pub data_index: Option<std::sync::Arc<Vec<Option<usize>>>>,
186 pub headers: std::sync::Arc<Vec<usize>>,
188 pub sticky: bool,
190 pub static_count: usize,
192}
193
194#[derive(Debug, Clone, PartialEq)]
196pub enum ListViewSource {
197 Own,
199 Ancestor { id: String, rel: f32 },
202 Host { rel: f32 },
205}
206
207#[derive(Debug, Clone, PartialEq)]
209pub struct ListView {
210 pub source: ListViewSource,
211 pub viewport: f32,
212}
213
214#[derive(Debug, Clone, Default, PartialEq)]
216pub struct FieldErrors {
217 pub name: String,
218 pub messages: Vec<String>,
219}
220
221#[derive(Debug, Clone, Default, PartialEq)]
223pub struct TabDrag {
224 pub from_slot: usize,
226 pub slots: Vec<usize>,
228 pub centers: Vec<f32>,
230 pub order: Vec<usize>,
232 pub rtl: bool,
233}
234
235#[derive(Debug, Clone, Default)]
237pub struct LocalState {
238 pub tabs: HashMap<String, String>,
239 pub tab_fit: HashMap<String, Vec<usize>>,
242 pub tab_order: HashMap<String, Vec<usize>>,
245 pub tab_drags: HashMap<String, TabDrag>,
247 pub accordion: HashMap<String, Vec<String>>,
248 pub carousel: HashMap<String, i64>,
249 pub open: HashMap<String, bool>,
250 pub pressed_toggles: HashMap<String, bool>,
251 pub holds: HashMap<String, crate::hold::HoldTracker>,
253 pub lists: HashMap<String, ListWindow>,
254 pub scroll: HashMap<String, (f32, f32)>,
256 pub query: HashMap<String, String>,
258 pub month: HashMap<String, (i32, u32)>,
260 pub range_anchor: HashMap<String, String>,
262 pub submenu: HashMap<String, usize>,
264 pub active: HashMap<String, usize>,
268 pub context_point: HashMap<String, (f32, f32)>,
270 pub field_values: HashMap<String, Value>,
272 pub errors: HashMap<String, FieldErrors>,
274 pub sort: HashMap<String, (String, String)>,
276 pub selected: HashMap<String, Vec<String>>,
277 pub chips: HashMap<String, Vec<String>>,
279 pub files: HashMap<String, Vec<Value>>,
280 pub copied: HashMap<String, bool>,
282 pub static_keys: HashMap<String, std::sync::Arc<Vec<String>>>,
284 pub list_views: HashMap<String, ListView>,
286 pub surface_scroll: Option<(f32, f32)>,
290 pub sizes: HashMap<String, Vec<f64>>,
293 pub drags: HashMap<String, Vec<f64>>,
295 pub sticky_any: bool,
297 pub key_cache: HashMap<String, (u64, std::sync::Arc<Vec<String>>)>,
301}
302
303pub struct BuildContext<'a> {
304 pub view: &'a CatalogView,
305 pub recipes: &'a RecipeIndex,
306 pub data: &'a Value,
307 pub local: &'a mut LocalState,
308 pub template: &'a dyn Fn(&str) -> Option<UiNode>,
310 pub viewport_height: f32,
311 pub viewport_width: f32,
313 pub hairline: f32,
316 pub row_extent: f32,
317 pub menu_row: f32,
320 pub tab_bar: f32,
323 pub checkbox: f32,
326 pub gap_px: &'a dyn Fn(&str) -> f32,
328 pub expand_controls: bool,
331 pub strings: &'a StringTable,
333 pub breakpoint: Option<String>,
335 pub locale: &'a str,
337 pub wide: bool,
339 pub today: Option<String>,
341 pub formatter: &'a dyn crate::format::Formatter,
344 pub now: f64,
346 pub data_version: u64,
348 pub read_only: bool,
351}
352
353pub struct Built {
354 pub nodes: Vec<LNode>,
355 pub layers: Vec<LayerSpec>,
356 pub lists: Vec<ListSpec>,
357 pub responsive: bool,
359 pub toasts: Vec<ToastSpec>,
361 pub row_scopes: HashMap<String, Value>,
364 pub forms: HashMap<String, (bool, bool)>,
366 pub issues: Vec<crate::types::ReduceIssue>,
368}
369
370#[derive(Debug, Clone, Default)]
373pub struct BuildSeed {
374 pub row_scopes: HashMap<String, Value>,
375 pub forms: HashMap<String, (bool, bool)>,
376}
377
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct ToastSpec {
381 pub id: String,
382 pub duration_ms: f64,
383 #[serde(rename = "type")]
384 pub kind: String,
385}
386
387pub(crate) struct Builder<'a, 'b> {
388 pub(crate) ctx: &'b mut BuildContext<'a>,
389 pub(crate) nodes: Vec<LNode>,
390 pub(crate) layers: Vec<LayerSpec>,
391 pub(crate) lists: Vec<ListSpec>,
392 pub(crate) responsive: bool,
393 pub(crate) toasts: Vec<ToastSpec>,
394 pub(crate) pending_toasts: Vec<(u32, UiNode)>,
396 pub(crate) next_layer: u32,
398 pub(crate) row_scopes: HashMap<String, Value>,
400 pub(crate) forms: HashMap<String, (bool, bool)>,
402 pub(crate) sources: HashMap<String, SourceRef>,
404 pub(crate) suffixes: HashMap<String, std::sync::Arc<Vec<String>>>,
406 pub(crate) flex_gaps: HashMap<u32, f32>,
408 pub(crate) template_items: usize,
410 pub(crate) template_nodes: usize,
414 pub(crate) template_refused: bool,
416 pub(crate) issues: Vec<crate::types::ReduceIssue>,
418}
419
420pub(crate) fn subtree_nodes(root: &UiNode) -> usize {
422 let mut count = 0;
423 let mut stack = vec![root];
424 while let Some(n) = stack.pop() {
425 count += 1;
426 stack.extend(n.children.iter());
427 stack.extend(n.slots.iter().flat_map(|s| s.values()));
428 }
429 count
430}
431
432pub(crate) fn obj(v: Value) -> Props {
433 match v {
434 Value::Object(m) => m,
435 _ => Map::new(),
436 }
437}
438
439fn with_suffix(node: &UiNode, suffix: &str, template: Option<&str>, sources: &mut HashMap<String, SourceRef>) -> UiNode {
440 let mut out = node.clone();
441 out.id = format!("{}{suffix}", node.id);
442 let source = sources.get(&node.id).cloned().unwrap_or_else(|| SourceRef { id: node.id.clone(), template: template.map(str::to_string) });
445 sources.insert(out.id.clone(), source);
446 out.children = node.children.iter().map(|c| with_suffix(c, suffix, template, sources)).collect();
447 if let Some(slots) = &node.slots {
448 out.slots = Some(slots.iter().map(|(k, v)| (k.clone(), with_suffix(v, suffix, template, sources))).collect());
449 }
450 out
451}
452
453pub const OVERLAYS: &[&str] = &["Dialog", "Drawer", "Popover", "Tooltip", "Menu"];
455
456pub const FORM_FIELDS: &[&str] =
458 &["Input", "Textarea", "NumberField", "Checkbox", "Radio", "ChoiceTiles", "Switch", "Select", "ChipInput", "DatePicker", "DateRangePicker", "TimePicker", "FileUpload", "Slider"];
459
460pub(crate) fn display_text_props(def: &crate::types::ComponentDef, props: &mut Props) {
464 for (k, v) in props.iter_mut() {
465 if matches!(v, Value::Number(_) | Value::Bool(_)) && def.props.get(k).is_some_and(|s| s.type_ == "string" || s.type_ == "markdown") {
466 *v = Value::String(crate::format::display_string(v));
467 }
468 }
469}
470
471pub(crate) fn str_prop<'p>(props: &'p Props, key: &str) -> Option<&'p str> {
472 props.get(key).and_then(Value::as_str)
473}
474
475pub(crate) fn recipe_props(props: &Props, form_disabled: bool) -> std::borrow::Cow<'_, Props> {
478 if !form_disabled {
479 return std::borrow::Cow::Borrowed(props);
480 }
481 let mut own = props.clone();
482 own.remove("disabled");
483 std::borrow::Cow::Owned(own)
484}
485
486pub(crate) fn bool_prop(props: &Props, key: &str) -> bool {
487 props.get(key).and_then(Value::as_bool).unwrap_or(false)
488}
489
490pub(crate) fn js(v: &Value) -> String {
491 crate::json::to_js_string(v)
492}
493
494impl<'a, 'b> Builder<'a, 'b> {
495 pub(crate) fn resolve_ctx<'s>(&'s self, scope: &'s str) -> ResolveContext<'s> {
496 let ctx = ResolveContext::new(self.ctx.data, scope).with_strings(self.ctx.strings).with_formatter(self.ctx.formatter).with_now(self.ctx.now);
497 match row_scope_root(scope).and_then(|root| self.row_scopes.get_key_value(root)) {
498 Some((root, row)) => ctx.with_overlay(root, row),
499 None => ctx,
500 }
501 }
502
503 pub(crate) fn resolve(&self, value: &Value, scope: &str) -> Option<Value> {
504 resolve_value(value, &self.resolve_ctx(scope))
505 }
506
507 pub(crate) fn resolve_map(&self, map: &Props, scope: &str) -> Props {
508 obj(resolve_value(&Value::Object(map.clone()), &self.resolve_ctx(scope)).unwrap_or(Value::Null))
509 }
510
511 pub(crate) fn string(&self, id: &str) -> String {
513 self.ctx.strings.get(id).cloned().unwrap_or_else(|| id.to_string())
514 }
515
516 pub(crate) fn string_with(&self, id: &str, params: &[(&str, &str)]) -> String {
519 let params: Map<String, Value> = params.iter().map(|(k, v)| (k.to_string(), Value::String(v.to_string()))).collect();
520 crate::strings::format_string(&self.string(id), ¶ms)
521 }
522
523 pub(crate) fn instance(&mut self, node: &UiNode, suffix: &str, template: Option<&str>) -> UiNode {
527 with_suffix(node, suffix, template, &mut self.sources)
528 }
529
530 pub(crate) fn push(&mut self, mut node: LNode) -> u32 {
531 let index = self.nodes.len() as u32;
532 node.index = index;
533 if let Some(p) = node.parent {
534 self.nodes[p as usize].children.push(index);
535 }
536 self.nodes.push(node);
537 index
538 }
539
540 pub(crate) fn blank(&self, id: &str, component: &str, parent: Option<u32>, layer: u32, kind: NodeKind, scope: &str) -> LNode {
541 let (depth, hidden, form) = parent
542 .map(|p| {
543 let pn = &self.nodes[p as usize];
544 (pn.depth + 1, pn.hidden, pn.form.clone())
545 })
546 .unwrap_or((0, false, None));
547 LNode {
548 index: 0,
549 id: id.to_string(),
550 component: component.to_string(),
551 part: None,
552 owner: None,
553 owner_component: None,
554 catalog_id: None,
555 extension_kind: None,
556 depth,
557 parent,
558 children: Vec::new(),
559 layer,
560 kind,
561 props: Map::new(),
562 base_style: Map::new(),
563 default_keys: Vec::new(),
564 own_query: None,
565 part_query: None,
566 authored_keys: Vec::new(),
567 hidden,
568 lines: None,
569 pressable: false,
570 scope: scope.to_string(),
571 source: None,
572 on: None,
573 accessibility: None,
574 trigger_for: None,
575 form,
576 form_disabled: false,
577 live: None,
578 }
579 }
580
581 #[allow(clippy::too_many_arguments)]
584 pub(crate) fn part_in(&mut self, parent: u32, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value, suffix: &str) -> u32 {
585 let id = format!("{}.{part}{suffix}", owner.id);
586 let layer = self.nodes[parent as usize].layer;
587 let mut n = self.blank(&id, component, Some(parent), layer, kind, &owner.scope);
588 n.part = Some(part.to_string());
589 n.owner = Some(owner.id.clone());
590 n.owner_component = Some(owner.component.clone());
591 n.base_style = obj(style);
592 n.props = obj(props);
593 let query_props = self.ctx.recipes.native_recipe_props(&owner.component, &recipe_props(&owner.props, owner.form_disabled));
594 n.part_query = Some(RecipeQuery::new(owner.component.clone(), part, query_props, Vec::new()));
595 if let Some(l) = n.props.get("lines").and_then(Value::as_u64) {
596 n.lines = Some(l as u32);
597 }
598 self.push(n)
599 }
600
601 pub(crate) fn part(&mut self, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value) -> u32 {
603 self.part_in(owner.index, owner, part, component, kind, style, props, "")
604 }
605
606 pub(crate) fn text_part(&mut self, parent: u32, owner: &LNode, part: &str, text: &str, variant: &str, suffix: &str) -> u32 {
607 self.part_in(parent, owner, part, "Text", NodeKind::Leaf, json!({}), json!({"text": text, "variant": variant}), suffix)
608 }
609
610 pub(crate) fn icon_part(&mut self, parent: u32, owner: &LNode, part: &str, glyph_slot: &str, suffix: &str) -> u32 {
612 let name = builtin_icon(glyph_slot).unwrap_or("ui-icon-placeholder");
613 self.part_in(parent, owner, part, "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": name, "size": "sm"}), suffix)
614 }
615
616 pub(crate) fn default_a11y(&mut self, index: u32, key: &str, value: Value) {
619 let a = self.nodes[index as usize].accessibility.get_or_insert_with(|| json!({}));
620 if let Some(m) = a.as_object_mut() {
621 m.entry(key.to_string()).or_insert(value);
622 }
623 }
624
625 pub(crate) fn add_state(&mut self, index: u32, state: &str) {
627 if let Some(q) = &mut self.nodes[index as usize].part_query {
628 if !q.states.iter().any(|s| s == state) {
629 q.states.push(state.to_string());
630 }
631 }
632 }
633
634 pub(crate) fn query_prop(&mut self, index: u32, key: &str, value: Value) {
636 if let Some(q) = &mut self.nodes[index as usize].part_query {
637 q.props.insert(key.to_string(), value);
638 }
639 }
640
641 pub(crate) fn style_default(&mut self, index: u32, entries: &[(&str, Value)]) {
642 let n = &mut self.nodes[index as usize];
643 for (k, v) in entries {
644 if !n.base_style.contains_key(*k) {
645 n.base_style.insert(k.to_string(), v.clone());
646 n.default_keys.push(k.to_string());
647 }
648 }
649 }
650
651 fn media_ratio(&mut self, index: u32) {
655 let n = &self.nodes[index as usize];
656 let given = n.props.get("aspectRatio").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0);
657 let has_height = n.props.get("height").is_some_and(|v| !v.is_null()) || n.base_style.contains_key("height");
658 let ratio = given.or_else(|| (n.component == "Video" || !has_height).then_some(crate::layout::MEDIA_ASPECT_RATIO));
659 if let Some(r) = ratio {
660 self.style_default(index, &[("aspectRatio", json!(r))]);
661 }
662 }
663
664 fn zoomable_box(&mut self, index: u32) {
668 let n = &self.nodes[index as usize];
669 let has_height = n.base_style.contains_key("height");
670 let ratio = zoomable_aspect_ratio(&n.props);
671 self.style_default(index, &[("width", json!("100%"))]);
672 if !has_height {
673 self.style_default(index, &[("aspectRatio", json!(ratio))]);
674 }
675 }
676
677 fn motion_box(&mut self, index: u32) {
683 let n = &self.nodes[index as usize];
684 let Some((w, h)) = motion_size(&n.props) else { return };
685 let (has_w, has_h) = (n.base_style.contains_key("width"), n.base_style.contains_key("height"));
686 if !has_w && !has_h {
687 self.style_default(index, &[("width", json!(w)), ("maxWidth", json!("100%"))]);
688 }
689 if !(has_w && has_h) {
690 self.style_default(index, &[("aspectRatio", json!(w / h))]);
691 }
692 }
693
694 pub(crate) fn is_overlay(component: &str) -> bool {
695 OVERLAYS.contains(&component)
696 }
697
698 fn visible(&self, node: &UiNode, scope: &str) -> bool {
700 crate::data::is_visible(node.visible.as_ref(), &self.resolve_ctx(scope))
701 }
702
703 pub(crate) fn add(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
705 crate::deep(|| self.add_node(node, parent, layer, scope, force_hidden))
706 }
707
708 fn add_node(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
709 if !self.visible(node, scope) {
710 return None;
711 }
712 let view = self.ctx.view;
713 let def = view.component(&node.component);
714 let mut props = crate::data::resolve_node_props(def, &node.props, &self.resolve_ctx(scope), &view.defs);
718 if let Some(def) = def {
719 display_text_props(def, &mut props);
720 }
721 self.local_overrides(&node.id, &node.component, &mut props);
722 if self.ctx.read_only && crate::read_only::disables(&node.component, node.on.as_ref()) {
726 props.insert("disabled".into(), Value::Bool(true));
727 }
728 let form = parent.and_then(|p| self.nodes[p as usize].form.clone());
731 let mut form_disabled = false;
732 if let Some((disabled, busy)) = form.as_ref().and_then(|f| self.forms.get(f)).copied() {
733 if disabled && (FORM_FIELDS.contains(&node.component.as_str()) || node.component == "Button") {
734 form_disabled = !bool_prop(&props, "disabled");
735 props.insert("disabled".into(), Value::Bool(true));
736 }
737 if busy && node.component == "Button" && bool_prop(&props, "submit") {
738 props.insert("loading".into(), Value::Bool(true));
739 }
740 }
741 let ext = view.extension_of(&node.component).map(str::to_string);
742 let children_rule = def.map(|d| d.children.as_str()).unwrap_or("none");
743 let has_children = !node.children.is_empty() || node.template.is_some();
744 let component = if ext.is_some() { "Extension".to_string() } else { node.component.clone() };
745 let kind = if ext.is_some() {
746 if has_children {
747 NodeKind::Container
748 } else {
749 NodeKind::Leaf
750 }
751 } else {
752 match (node.component.as_str(), children_rule) {
753 ("Box", _) => NodeKind::Container,
754 (_, "none") => NodeKind::Leaf,
755 ("Button", "one") => {
756 if has_children {
757 NodeKind::Container
758 } else {
759 NodeKind::Leaf
760 }
761 }
762 _ => NodeKind::Container,
763 }
764 };
765 let mut n = self.blank(&node.id, &component, parent, layer, kind, scope);
766 n.hidden |= force_hidden;
767 if let Some(e) = ext {
768 n.catalog_id = Some(e);
769 n.extension_kind = Some(node.component.clone());
770 }
771 let mut base: Props = props.get("style").and_then(Value::as_object).cloned().unwrap_or_default();
774 if let Some(s) = &node.style {
775 for (k, v) in self.resolve_map(s, scope) {
776 base.insert(k, v);
777 }
778 }
779 n.base_style = base;
780 n.lines = props.get("lines").and_then(Value::as_u64).map(|l| l as u32);
781 n.pressable = (node.on.as_ref().is_some_and(|o| o.contains_key("press")) || bool_prop(&props, "pressable") || (node.component == "Button" && (bool_prop(&props, "submit") || bool_prop(&props, "hold"))) || (matches!(node.component.as_str(), "Box" | "Button" | "Link") && str_prop(&props, "href").is_some_and(|href| !href.is_empty()))) && !bool_prop(&props, "disabled") && !bool_prop(&props, "loading");
782 n.on = node.on.clone();
783 n.source = self.sources.get(&node.id).cloned();
784 n.accessibility = node.accessibility.as_ref().and_then(|a| self.resolve(a, scope));
785 n.live = str_prop(&props, "live").filter(|l| *l != "off").map(str::to_string);
786 n.form_disabled = form_disabled;
787 n.own_query = Some(RecipeQuery::new(node.component.clone(), "root", self.ctx.recipes.native_recipe_props(&node.component, &recipe_props(&props, form_disabled)), Vec::new()));
788 if let Some(r) = &node.recipe {
789 n.part_query = Some(RecipeQuery::new(r.macro_.clone(), r.part.clone(), self.resolve_map(&r.props, scope), Vec::new()));
790 n.authored_keys = r.authored.clone();
791 }
792 if node.component == "Form" {
793 n.form = Some(node.id.clone());
794 }
795 n.props = props;
796 let index = self.push(n);
797 if node.component == "Button" && self.ctx.local.holds.get(&node.id).is_some_and(crate::hold::HoldTracker::active) {
799 self.add_state_own(index, "pressed");
800 }
801 if let Some(errors) = self.ctx.local.errors.get(&node.id) {
802 if !errors.messages.is_empty() {
803 let _ = errors;
804 self.add_state_own(index, "invalid");
805 }
806 }
807
808 let expand = self.ctx.expand_controls;
811 match node.component.as_str() {
813 "Image" | "Video" => self.media_ratio(index),
814 "ZoomableImage" => self.zoomable_box(index),
815 "Motion" => self.motion_box(index),
816 "Segmented" if crate::segmented::is_rail(&self.nodes[index as usize].props) => {
821 let tall = crate::segmented::rail_height(&self.nodes[index as usize].props, self.ctx.tab_bar);
822 self.style_default(index, &[("alignSelf", json!("flex-start")), ("flexShrink", json!(0)), ("height", json!(tall))])
823 }
824 "Segmented" if str_prop(&self.nodes[index as usize].props, "variant") == Some("bar") => {
828 self.style_default(index, &[("width", json!("100%"))])
829 }
830 "Segmented" if !bool_prop(&self.nodes[index as usize].props, "fill") && str_prop(&self.nodes[index as usize].props, "variant") != Some("bar") => {
832 self.style_default(index, &[("alignSelf", json!("flex-start"))])
833 }
834 "Button" if self.nodes[index as usize].kind == NodeKind::Container => {
838 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("center")), ("justifyContent", json!("center"))])
839 }
840 "VoiceWave" => self.style_default(index, &[("display", json!("flex")), ("alignItems", json!("center")), ("justifyContent", json!("center"))]),
842 "TreeGuides" => {
844 let depth = self.nodes[index as usize].props.get("depth").and_then(Value::as_f64).unwrap_or(0.0).max(0.0);
845 self.style_default(index, &[("alignSelf", json!("stretch")), ("width", json!(depth * crate::layout::TREE_GUIDE_COLUMN)), ("flexShrink", json!(0))]);
846 }
847 _ => {}
848 }
849 match node.component.as_str() {
850 "Tabs" => self.tabs(index, node, scope),
851 "Accordion" => self.accordion(index, node, scope),
852 "Carousel" => self.carousel(index, node, scope),
853 "List" => self.list(index, node, scope),
854 "Resizable" => self.resizable(index, node, scope),
855 "Table" => self.table(index, node, scope),
856 "Toast" => self.toast(index, node),
857 c if Self::is_overlay(c) => self.overlay(index, node, scope),
858 "Form" => self.form(index, node, scope),
859 "Input" | "Textarea" if expand => self.field(index),
860 "Select" if expand => self.select(index),
861 "DatePicker" | "DateRangePicker" if expand => self.date_picker(index),
862 "TimePicker" if expand => self.time_picker(index),
863 "NumberField" if expand => self.number_field(index),
864 "ChipInput" if expand => self.chip_input(index),
865 "FileUpload" if expand => self.file_upload(index),
866 "CodeBlock" if expand => self.code_block(index),
867 "Checkbox" | "Switch" if expand => self.check(index),
868 "Radio" if expand => self.radio(index),
869 "ChoiceTiles" if expand => self.choice_tiles(index),
870 "Slider" if expand => self.slider(index),
871 "SearchList" if expand => self.search_list(index),
872 "Skeleton" => self.skeleton_rows(index),
873 "Chart" => self.chart(index),
874 _ => {
875 if let Some(slots) = &node.slots {
876 for slot in slots.values() {
877 self.add(slot, Some(index), layer, scope, false);
878 }
879 }
880 self.children_of(index, node, scope, false);
881 }
882 }
883 Some(index)
884 }
885
886 fn add_state_own(&mut self, index: u32, state: &str) {
888 if let Some(q) = &mut self.nodes[index as usize].own_query {
889 if !q.states.iter().any(|s| s == state) {
890 q.states.push(state.to_string());
891 }
892 }
893 }
894
895 fn local_overrides(&self, id: &str, component: &str, props: &mut Props) {
898 let local = &self.ctx.local;
899 if let Some(v) = local.field_values.get(id) {
900 match component {
901 "DateRangePicker" => {
902 for k in ["start", "end"] {
903 if let Some(x) = v.get(k) {
904 props.insert(k.into(), x.clone());
905 }
906 }
907 }
908 _ => {
909 let key = match component {
910 "Checkbox" | "Switch" => "checked",
911 "ChipInput" => "values",
912 "FileUpload" => "files",
913 _ => "value",
914 };
915 props.insert(key.into(), v.clone());
916 }
917 }
918 }
919 if component == "Button" && local.holds.get(id).is_some_and(crate::hold::HoldTracker::active) {
921 if let Some(label) = props.get("holdingLabel").and_then(Value::as_str).filter(|l| !l.is_empty()).map(str::to_string) {
922 props.insert("label".into(), Value::String(label));
923 }
924 }
925 if component == "Toggle" {
926 if let Some(pressed) = local.pressed_toggles.get(id) {
927 props.insert("pressed".into(), Value::Bool(*pressed));
928 }
929 }
930 if component == "ChipInput" {
931 if let Some(chips) = local.chips.get(id) {
932 props.insert("values".into(), Value::Array(chips.iter().cloned().map(Value::String).collect()));
933 }
934 }
935 if component == "FileUpload" {
936 if let Some(files) = local.files.get(id) {
937 props.insert("files".into(), Value::Array(files.clone()));
938 }
939 }
940 if component == "Table" {
941 if let Some((key, dir)) = local.sort.get(id) {
942 props.insert("sort".into(), json!({"key": key, "direction": dir}));
943 }
944 if let Some(sel) = local.selected.get(id) {
945 props.insert("selected".into(), Value::Array(sel.iter().cloned().map(Value::String).collect()));
946 }
947 }
948 }
949
950 fn item_suffixes(&mut self, t: &crate::types::Template, base: &str) -> std::sync::Arc<Vec<String>> {
956 let cache_key = format!("{base}\u{0}{}", t.key.as_deref().unwrap_or(""));
957 if let Some(s) = self.suffixes.get(&cache_key) {
958 return s.clone();
959 }
960 let version = self.ctx.data_version;
961 if let Some((v, keys)) = self.ctx.local.key_cache.get(&format!("s\u{0}{cache_key}")) {
962 if *v == version {
963 let keys = keys.clone();
964 self.suffixes.insert(cache_key, keys.clone());
965 return keys;
966 }
967 }
968 let items = crate::data::get_pointer(self.ctx.data, base).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
969 let out: Vec<String> = crate::list::template_item_keys(items, t.key.as_deref()).into_iter().map(|k| format!(".{}", crate::list::instance_segment(&k))).collect();
970 let out = std::sync::Arc::new(out);
971 self.ctx.local.key_cache.insert(format!("s\u{0}{cache_key}"), (version, out.clone()));
972 self.suffixes.insert(cache_key, out.clone());
973 out
974 }
975
976 pub(crate) fn enclosing_suffix(&self, node: &UiNode) -> String {
980 match self.sources.get(&node.id) {
981 Some(src) if node.id.len() > src.id.len() && node.id.starts_with(src.id.as_str()) => node.id[src.id.len()..].to_string(),
982 _ => String::new(),
983 }
984 }
985
986 pub(crate) fn children_of(&mut self, parent: u32, node: &UiNode, scope: &str, hidden: bool) -> Vec<u32> {
988 let mut out = Vec::new();
989 let layer = self.nodes[parent as usize].layer;
990 for child in &node.children {
991 out.extend(self.add(child, Some(parent), layer, scope, hidden));
992 }
993 if let Some(t) = &node.template {
994 let base = crate::data::absolute_path(&t.path, scope);
995 let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len).unwrap_or(0);
996 if let Some(tpl) = (self.ctx.template)(&t.component) {
997 let suffixes = self.item_suffixes(t, &base);
998 let enclosing = self.enclosing_suffix(node);
999 for i in 0..count {
1000 if !self.take_template_item(&t.component, &tpl) {
1001 break;
1002 }
1003 let item_scope = format!("{base}/{i}");
1004 let suffix = format!("{enclosing}{}", suffixes[i]);
1005 let item = self.instance(&tpl, &suffix, Some(&t.component));
1006 out.extend(self.add(&item, Some(parent), layer, &item_scope, hidden));
1007 }
1008 }
1009 }
1010 out
1011 }
1012
1013 fn take_template_item(&mut self, template: &str, tpl: &UiNode) -> bool {
1021 if self.template_refused {
1022 return false;
1023 }
1024 let nodes = subtree_nodes(tpl);
1025 let message = if self.template_items >= crate::limits::MAX_TEMPLATE_ITEMS {
1026 crate::limits::template_items_issue()
1027 } else if self.template_nodes + nodes > crate::limits::MAX_COMPONENTS {
1028 crate::limits::components_issue()
1029 } else {
1030 self.template_items += 1;
1031 self.template_nodes += nodes;
1032 return true;
1033 };
1034 self.template_refused = true;
1035 self.issues.push(crate::types::ReduceIssue { id: template.to_string(), message });
1036 false
1037 }
1038
1039 pub(crate) fn child_keys(&mut self, node: &UiNode, scope: &str) -> std::sync::Arc<Vec<String>> {
1043 if node.template.is_none() {
1044 if let Some(k) = self.ctx.local.static_keys.get(&node.id) {
1045 if k.len() == node.children.len() {
1046 return k.clone();
1047 }
1048 }
1049 let keys = std::sync::Arc::new(node.children.iter().map(|c| c.id.clone()).collect::<Vec<_>>());
1050 self.ctx.local.static_keys.insert(node.id.clone(), keys.clone());
1051 return keys;
1052 }
1053 let enclosing = self.enclosing_suffix(node);
1054 let cache_key = format!("c\u{0}{}\u{0}{scope}", node.id);
1055 if let Some((v, keys)) = self.ctx.local.key_cache.get(&cache_key) {
1056 if *v == self.ctx.data_version && keys.len() >= node.children.len() {
1057 return keys.clone();
1058 }
1059 }
1060 let mut keys: Vec<String> = node.children.iter().map(|c| c.id.clone()).collect();
1061 if let Some(t) = &node.template {
1062 let base = crate::data::absolute_path(&t.path, scope);
1063 for suffix in self.item_suffixes(t, &base).iter() {
1064 keys.push(format!("{}{enclosing}{suffix}", t.component));
1065 }
1066 }
1067 let keys = std::sync::Arc::new(keys);
1068 self.ctx.local.key_cache.insert(cache_key, (self.ctx.data_version, keys.clone()));
1069 keys
1070 }
1071
1072 pub(crate) fn one_child(&mut self, parent: u32, node: &UiNode, i: usize, scope: &str, hidden: bool) -> Option<u32> {
1074 let layer = self.nodes[parent as usize].layer;
1075 if let Some(child) = node.children.get(i) {
1076 return self.add(child, Some(parent), layer, scope, hidden);
1077 }
1078 let t = node.template.as_ref()?;
1079 let base = crate::data::absolute_path(&t.path, scope);
1080 let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len)?;
1081 let idx = i.checked_sub(node.children.len())?;
1082 if idx >= count {
1083 return None;
1084 }
1085 let tpl = (self.ctx.template)(&t.component)?;
1086 if !self.take_template_item(&t.component, &tpl) {
1087 return None;
1088 }
1089 let item_scope = format!("{base}/{idx}");
1090 let suffix = format!("{}{}", self.enclosing_suffix(node), self.item_suffixes(t, &base).get(idx).cloned().unwrap_or_else(|| format!(".{idx}")));
1091 let item = self.instance(&tpl, &suffix, Some(&t.component));
1092 self.add(&item, Some(parent), layer, &item_scope, hidden)
1093 }
1094
1095 pub(crate) fn responsive_prop(&mut self, props: &Props, key: &str) -> Option<Value> {
1097 let v = props.get(key)?;
1098 if crate::macros::is_responsive_value(v) {
1099 self.responsive = true;
1100 return crate::macros::responsive_at(v, self.ctx.breakpoint.as_deref()).cloned();
1101 }
1102 Some(v.clone())
1103 }
1104
1105 pub(crate) fn is_open(&self, id: &str, props: &Props, default: bool) -> bool {
1107 self.ctx.local.open.get(id).copied().unwrap_or_else(|| props.get("open").and_then(Value::as_bool).unwrap_or(default))
1108 }
1109
1110 pub(crate) fn layer_root(&mut self, owner: &LNode, part: &str, style: Value) -> (u32, u32) {
1112 let layer = self.next_layer;
1113 self.next_layer += 1;
1114 let mut root = self.blank(&format!("{}.{part}", owner.id), "Box", None, layer, NodeKind::Container, &owner.scope);
1115 root.part = Some(part.to_string());
1116 root.owner = Some(owner.id.clone());
1117 root.owner_component = Some(owner.component.clone());
1118 root.form = owner.form.clone();
1119 root.base_style = obj(style);
1120 root.part_query = Some(RecipeQuery::new(owner.component.clone(), part, self.ctx.recipes.native_recipe_props(&owner.component, &recipe_props(&owner.props, owner.form_disabled)), vec!["open".into()]));
1121 let index = self.push(root);
1122 (layer, index)
1123 }
1124
1125 #[allow(clippy::too_many_arguments)]
1126 pub(crate) fn push_layer(&mut self, layer: u32, root: u32, owner: &LNode, placement: LayerPlacement, modal: bool, dismissible: bool, match_anchor_width: bool, scroll_body: Option<u32>) {
1127 let class = if matches!(placement, LayerPlacement::Toast { .. }) { LayerClass::Toast } else { LayerClass::Overlay };
1128 self.layers.push(LayerSpec { layer, root, kind: owner.component.clone(), owner: owner.id.clone(), placement, class, modal, dismissible, match_anchor_width, scroll_body, mirror: false });
1129 }
1130}
1131
1132pub fn row_scope_root(scope: &str) -> Option<&str> {
1135 let rest = scope.strip_prefix("/$row/")?;
1136 let mut end = "/$row/".len();
1137 for (k, part) in rest.splitn(3, '/').enumerate().take(2) {
1138 end += part.len() + usize::from(k == 0);
1139 }
1140 Some(&scope[..end])
1141}
1142
1143pub fn builtin_icon(slot: &str) -> Option<&'static str> {
1145 let g = crate::generated::catalog::BUILTIN_ICON_SLOTS.iter().position(|s| *s == slot)?;
1146 crate::generated::catalog::BUILTIN_ICON_NAMES.get(g).copied()
1147}
1148
1149pub fn build(root: &UiNode, ctx: &mut BuildContext) -> Built {
1152 let mut b = Builder { ctx, nodes: Vec::new(), layers: Vec::new(), lists: Vec::new(), responsive: false, toasts: Vec::new(), pending_toasts: Vec::new(), next_layer: 1, row_scopes: HashMap::new(), forms: HashMap::new(), sources: HashMap::new(), suffixes: HashMap::new(), flex_gaps: HashMap::new(), template_items: 0, template_nodes: subtree_nodes(root), template_refused: false, issues: Vec::new() };
1153 if b.add(root, None, 0, "", false).is_none() {
1154 let mut n = b.blank(&root.id, "Box", None, 0, NodeKind::Container, "");
1156 n.hidden = true;
1157 b.push(n);
1158 }
1159 b.flush_toasts();
1160 b.layers.sort_by_key(|l| (l.class == LayerClass::Toast, l.layer));
1163 Built { nodes: b.nodes, layers: b.layers, lists: b.lists, responsive: b.responsive, toasts: b.toasts, row_scopes: b.row_scopes, forms: b.forms, issues: b.issues }
1164}
1165
1166pub fn build_subtree(node: &UiNode, parent: &LNode, scope: &str, seed: BuildSeed, ctx: &mut BuildContext) -> Built {
1172 let mut stub = parent.clone();
1173 stub.index = 0;
1174 stub.parent = None;
1175 stub.children.clear();
1176 let layer = stub.layer;
1177 let mut b = Builder { ctx, nodes: vec![stub], layers: Vec::new(), lists: Vec::new(), responsive: false, toasts: Vec::new(), pending_toasts: Vec::new(), next_layer: u32::MAX / 2, row_scopes: seed.row_scopes, forms: seed.forms, sources: HashMap::new(), suffixes: HashMap::new(), flex_gaps: HashMap::new(), template_items: 0, template_nodes: subtree_nodes(node), template_refused: false, issues: Vec::new() };
1178 b.add(node, Some(0), layer, scope, false);
1179 b.flush_toasts();
1180 Built { nodes: b.nodes, layers: b.layers, lists: b.lists, responsive: b.responsive, toasts: b.toasts, row_scopes: b.row_scopes, forms: b.forms, issues: b.issues }
1181}
1182
1183pub fn motion_size(props: &Props) -> Option<(f64, f64)> {
1186 let pos = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1187 let size = props.get("size").and_then(Value::as_array);
1188 let from_size = size.and_then(|a| Some((pos(a.first())?, pos(a.get(1))?)));
1189 from_size.or_else(|| {
1190 let l = props.get("lottie")?;
1191 Some((pos(l.get("w"))?, pos(l.get("h"))?))
1192 })
1193}
1194
1195pub fn zoomable_aspect_ratio(props: &Props) -> f64 {
1200 let num = |k: &str| props.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1201 match (num("naturalWidth"), num("naturalHeight")) {
1202 (Some(w), Some(h)) if crate::zoom::zoom_is_tall(crate::zoom::ZoomSize { width: w, height: h }) => 1.0,
1203 (Some(w), Some(h)) => w / h,
1204 _ => crate::layout::MEDIA_ASPECT_RATIO,
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211
1212 #[test]
1213 fn builtin_glyphs_resolve() {
1214 assert_eq!(builtin_icon("CodeBlock.copy"), Some("ui-copy"));
1215 assert_eq!(builtin_icon("Table.sortIcon.asc"), Some("ui-chevron-up"));
1216 assert_eq!(builtin_icon("Nope.x"), None);
1217 }
1218
1219 #[test]
1220 fn a_zoomable_box_follows_its_picture() {
1221 let p = |v: Value| v.as_object().cloned().unwrap_or_default();
1222 assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 1440, "naturalHeight": 900}))) - 1.6).abs() < 1e-9);
1223 assert_eq!(zoomable_aspect_ratio(&p(json!({"naturalWidth": 1000, "naturalHeight": 8000}))), 1.0);
1225 assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 600, "naturalHeight": 900}))) - 600.0 / 900.0).abs() < 1e-9);
1226 assert_eq!(zoomable_aspect_ratio(&p(json!({}))), crate::layout::MEDIA_ASPECT_RATIO);
1227 }
1228}