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)]
223pub struct LocalState {
224 pub tabs: HashMap<String, String>,
225 pub accordion: HashMap<String, Vec<String>>,
226 pub carousel: HashMap<String, i64>,
227 pub open: HashMap<String, bool>,
228 pub pressed_toggles: HashMap<String, bool>,
229 pub holds: HashMap<String, crate::hold::HoldTracker>,
231 pub lists: HashMap<String, ListWindow>,
232 pub scroll: HashMap<String, (f32, f32)>,
234 pub query: HashMap<String, String>,
236 pub month: HashMap<String, (i32, u32)>,
238 pub range_anchor: HashMap<String, String>,
240 pub submenu: HashMap<String, usize>,
242 pub active: HashMap<String, usize>,
246 pub context_point: HashMap<String, (f32, f32)>,
248 pub field_values: HashMap<String, Value>,
250 pub errors: HashMap<String, FieldErrors>,
252 pub sort: HashMap<String, (String, String)>,
254 pub selected: HashMap<String, Vec<String>>,
255 pub chips: HashMap<String, Vec<String>>,
257 pub files: HashMap<String, Vec<Value>>,
258 pub copied: HashMap<String, bool>,
260 pub static_keys: HashMap<String, std::sync::Arc<Vec<String>>>,
262 pub list_views: HashMap<String, ListView>,
264 pub surface_scroll: Option<(f32, f32)>,
268 pub sizes: HashMap<String, Vec<f64>>,
271 pub drags: HashMap<String, Vec<f64>>,
273 pub sticky_any: bool,
275 pub key_cache: HashMap<String, (u64, std::sync::Arc<Vec<String>>)>,
279}
280
281pub struct BuildContext<'a> {
282 pub view: &'a CatalogView,
283 pub recipes: &'a RecipeIndex,
284 pub data: &'a Value,
285 pub local: &'a mut LocalState,
286 pub template: &'a dyn Fn(&str) -> Option<UiNode>,
288 pub viewport_height: f32,
289 pub viewport_width: f32,
291 pub hairline: f32,
294 pub row_extent: f32,
295 pub menu_row: f32,
298 pub tab_bar: f32,
301 pub checkbox: f32,
304 pub gap_px: &'a dyn Fn(&str) -> f32,
306 pub expand_controls: bool,
309 pub strings: &'a StringTable,
311 pub breakpoint: Option<String>,
313 pub locale: &'a str,
315 pub wide: bool,
317 pub today: Option<String>,
319 pub formatter: &'a dyn crate::format::Formatter,
322 pub now: f64,
324 pub data_version: u64,
326}
327
328pub struct Built {
329 pub nodes: Vec<LNode>,
330 pub layers: Vec<LayerSpec>,
331 pub lists: Vec<ListSpec>,
332 pub responsive: bool,
334 pub toasts: Vec<ToastSpec>,
336 pub row_scopes: HashMap<String, Value>,
339 pub forms: HashMap<String, (bool, bool)>,
341 pub issues: Vec<crate::types::ReduceIssue>,
343}
344
345#[derive(Debug, Clone, Default)]
348pub struct BuildSeed {
349 pub row_scopes: HashMap<String, Value>,
350 pub forms: HashMap<String, (bool, bool)>,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct ToastSpec {
356 pub id: String,
357 pub duration_ms: f64,
358 #[serde(rename = "type")]
359 pub kind: String,
360}
361
362pub(crate) struct Builder<'a, 'b> {
363 pub(crate) ctx: &'b mut BuildContext<'a>,
364 pub(crate) nodes: Vec<LNode>,
365 pub(crate) layers: Vec<LayerSpec>,
366 pub(crate) lists: Vec<ListSpec>,
367 pub(crate) responsive: bool,
368 pub(crate) toasts: Vec<ToastSpec>,
369 pub(crate) pending_toasts: Vec<(u32, UiNode)>,
371 pub(crate) next_layer: u32,
373 pub(crate) row_scopes: HashMap<String, Value>,
375 pub(crate) forms: HashMap<String, (bool, bool)>,
377 pub(crate) sources: HashMap<String, SourceRef>,
379 pub(crate) suffixes: HashMap<String, std::sync::Arc<Vec<String>>>,
381 pub(crate) flex_gaps: HashMap<u32, f32>,
383 pub(crate) template_items: usize,
385 pub(crate) template_nodes: usize,
389 pub(crate) template_refused: bool,
391 pub(crate) issues: Vec<crate::types::ReduceIssue>,
393}
394
395pub(crate) fn subtree_nodes(root: &UiNode) -> usize {
397 let mut count = 0;
398 let mut stack = vec![root];
399 while let Some(n) = stack.pop() {
400 count += 1;
401 stack.extend(n.children.iter());
402 stack.extend(n.slots.iter().flat_map(|s| s.values()));
403 }
404 count
405}
406
407pub(crate) fn obj(v: Value) -> Props {
408 match v {
409 Value::Object(m) => m,
410 _ => Map::new(),
411 }
412}
413
414fn with_suffix(node: &UiNode, suffix: &str, template: Option<&str>, sources: &mut HashMap<String, SourceRef>) -> UiNode {
415 let mut out = node.clone();
416 out.id = format!("{}{suffix}", node.id);
417 let source = sources.get(&node.id).cloned().unwrap_or_else(|| SourceRef { id: node.id.clone(), template: template.map(str::to_string) });
420 sources.insert(out.id.clone(), source);
421 out.children = node.children.iter().map(|c| with_suffix(c, suffix, template, sources)).collect();
422 if let Some(slots) = &node.slots {
423 out.slots = Some(slots.iter().map(|(k, v)| (k.clone(), with_suffix(v, suffix, template, sources))).collect());
424 }
425 out
426}
427
428pub const OVERLAYS: &[&str] = &["Dialog", "Drawer", "Popover", "Tooltip", "Menu"];
430
431pub const FORM_FIELDS: &[&str] =
433 &["Input", "Textarea", "NumberField", "Checkbox", "Radio", "ChoiceTiles", "Switch", "Select", "ChipInput", "DatePicker", "DateRangePicker", "TimePicker", "FileUpload", "Slider"];
434
435pub(crate) fn display_text_props(def: &crate::types::ComponentDef, props: &mut Props) {
439 for (k, v) in props.iter_mut() {
440 if matches!(v, Value::Number(_) | Value::Bool(_)) && def.props.get(k).is_some_and(|s| s.type_ == "string" || s.type_ == "markdown") {
441 *v = Value::String(crate::format::display_string(v));
442 }
443 }
444}
445
446pub(crate) fn str_prop<'p>(props: &'p Props, key: &str) -> Option<&'p str> {
447 props.get(key).and_then(Value::as_str)
448}
449
450pub(crate) fn recipe_props(props: &Props, form_disabled: bool) -> std::borrow::Cow<'_, Props> {
453 if !form_disabled {
454 return std::borrow::Cow::Borrowed(props);
455 }
456 let mut own = props.clone();
457 own.remove("disabled");
458 std::borrow::Cow::Owned(own)
459}
460
461pub(crate) fn bool_prop(props: &Props, key: &str) -> bool {
462 props.get(key).and_then(Value::as_bool).unwrap_or(false)
463}
464
465pub(crate) fn js(v: &Value) -> String {
466 crate::json::to_js_string(v)
467}
468
469impl<'a, 'b> Builder<'a, 'b> {
470 pub(crate) fn resolve_ctx<'s>(&'s self, scope: &'s str) -> ResolveContext<'s> {
471 let ctx = ResolveContext::new(self.ctx.data, scope).with_strings(self.ctx.strings).with_formatter(self.ctx.formatter).with_now(self.ctx.now);
472 match row_scope_root(scope).and_then(|root| self.row_scopes.get_key_value(root)) {
473 Some((root, row)) => ctx.with_overlay(root, row),
474 None => ctx,
475 }
476 }
477
478 pub(crate) fn resolve(&self, value: &Value, scope: &str) -> Option<Value> {
479 resolve_value(value, &self.resolve_ctx(scope))
480 }
481
482 pub(crate) fn resolve_map(&self, map: &Props, scope: &str) -> Props {
483 obj(resolve_value(&Value::Object(map.clone()), &self.resolve_ctx(scope)).unwrap_or(Value::Null))
484 }
485
486 pub(crate) fn string(&self, id: &str) -> String {
488 self.ctx.strings.get(id).cloned().unwrap_or_else(|| id.to_string())
489 }
490
491 pub(crate) fn string_with(&self, id: &str, params: &[(&str, &str)]) -> String {
494 let params: Map<String, Value> = params.iter().map(|(k, v)| (k.to_string(), Value::String(v.to_string()))).collect();
495 crate::strings::format_string(&self.string(id), ¶ms)
496 }
497
498 pub(crate) fn instance(&mut self, node: &UiNode, suffix: &str, template: Option<&str>) -> UiNode {
502 with_suffix(node, suffix, template, &mut self.sources)
503 }
504
505 pub(crate) fn push(&mut self, mut node: LNode) -> u32 {
506 let index = self.nodes.len() as u32;
507 node.index = index;
508 if let Some(p) = node.parent {
509 self.nodes[p as usize].children.push(index);
510 }
511 self.nodes.push(node);
512 index
513 }
514
515 pub(crate) fn blank(&self, id: &str, component: &str, parent: Option<u32>, layer: u32, kind: NodeKind, scope: &str) -> LNode {
516 let (depth, hidden, form) = parent
517 .map(|p| {
518 let pn = &self.nodes[p as usize];
519 (pn.depth + 1, pn.hidden, pn.form.clone())
520 })
521 .unwrap_or((0, false, None));
522 LNode {
523 index: 0,
524 id: id.to_string(),
525 component: component.to_string(),
526 part: None,
527 owner: None,
528 owner_component: None,
529 catalog_id: None,
530 extension_kind: None,
531 depth,
532 parent,
533 children: Vec::new(),
534 layer,
535 kind,
536 props: Map::new(),
537 base_style: Map::new(),
538 default_keys: Vec::new(),
539 own_query: None,
540 part_query: None,
541 authored_keys: Vec::new(),
542 hidden,
543 lines: None,
544 pressable: false,
545 scope: scope.to_string(),
546 source: None,
547 on: None,
548 accessibility: None,
549 trigger_for: None,
550 form,
551 form_disabled: false,
552 live: None,
553 }
554 }
555
556 #[allow(clippy::too_many_arguments)]
559 pub(crate) fn part_in(&mut self, parent: u32, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value, suffix: &str) -> u32 {
560 let id = format!("{}.{part}{suffix}", owner.id);
561 let layer = self.nodes[parent as usize].layer;
562 let mut n = self.blank(&id, component, Some(parent), layer, kind, &owner.scope);
563 n.part = Some(part.to_string());
564 n.owner = Some(owner.id.clone());
565 n.owner_component = Some(owner.component.clone());
566 n.base_style = obj(style);
567 n.props = obj(props);
568 let query_props = self.ctx.recipes.native_recipe_props(&owner.component, &recipe_props(&owner.props, owner.form_disabled));
569 n.part_query = Some(RecipeQuery::new(owner.component.clone(), part, query_props, Vec::new()));
570 if let Some(l) = n.props.get("lines").and_then(Value::as_u64) {
571 n.lines = Some(l as u32);
572 }
573 self.push(n)
574 }
575
576 pub(crate) fn part(&mut self, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value) -> u32 {
578 self.part_in(owner.index, owner, part, component, kind, style, props, "")
579 }
580
581 pub(crate) fn text_part(&mut self, parent: u32, owner: &LNode, part: &str, text: &str, variant: &str, suffix: &str) -> u32 {
582 self.part_in(parent, owner, part, "Text", NodeKind::Leaf, json!({}), json!({"text": text, "variant": variant}), suffix)
583 }
584
585 pub(crate) fn icon_part(&mut self, parent: u32, owner: &LNode, part: &str, glyph_slot: &str, suffix: &str) -> u32 {
587 let name = builtin_icon(glyph_slot).unwrap_or("ui-icon-placeholder");
588 self.part_in(parent, owner, part, "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": name, "size": "sm"}), suffix)
589 }
590
591 pub(crate) fn default_a11y(&mut self, index: u32, key: &str, value: Value) {
594 let a = self.nodes[index as usize].accessibility.get_or_insert_with(|| json!({}));
595 if let Some(m) = a.as_object_mut() {
596 m.entry(key.to_string()).or_insert(value);
597 }
598 }
599
600 pub(crate) fn add_state(&mut self, index: u32, state: &str) {
602 if let Some(q) = &mut self.nodes[index as usize].part_query {
603 if !q.states.iter().any(|s| s == state) {
604 q.states.push(state.to_string());
605 }
606 }
607 }
608
609 pub(crate) fn query_prop(&mut self, index: u32, key: &str, value: Value) {
611 if let Some(q) = &mut self.nodes[index as usize].part_query {
612 q.props.insert(key.to_string(), value);
613 }
614 }
615
616 pub(crate) fn style_default(&mut self, index: u32, entries: &[(&str, Value)]) {
617 let n = &mut self.nodes[index as usize];
618 for (k, v) in entries {
619 if !n.base_style.contains_key(*k) {
620 n.base_style.insert(k.to_string(), v.clone());
621 n.default_keys.push(k.to_string());
622 }
623 }
624 }
625
626 fn media_ratio(&mut self, index: u32) {
630 let n = &self.nodes[index as usize];
631 let given = n.props.get("aspectRatio").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0);
632 let has_height = n.props.get("height").is_some_and(|v| !v.is_null()) || n.base_style.contains_key("height");
633 let ratio = given.or_else(|| (n.component == "Video" || !has_height).then_some(crate::layout::MEDIA_ASPECT_RATIO));
634 if let Some(r) = ratio {
635 self.style_default(index, &[("aspectRatio", json!(r))]);
636 }
637 }
638
639 fn zoomable_box(&mut self, index: u32) {
643 let n = &self.nodes[index as usize];
644 let has_height = n.base_style.contains_key("height");
645 let ratio = zoomable_aspect_ratio(&n.props);
646 self.style_default(index, &[("width", json!("100%"))]);
647 if !has_height {
648 self.style_default(index, &[("aspectRatio", json!(ratio))]);
649 }
650 }
651
652 fn motion_box(&mut self, index: u32) {
658 let n = &self.nodes[index as usize];
659 let Some((w, h)) = motion_size(&n.props) else { return };
660 let (has_w, has_h) = (n.base_style.contains_key("width"), n.base_style.contains_key("height"));
661 if !has_w && !has_h {
662 self.style_default(index, &[("width", json!(w)), ("maxWidth", json!("100%"))]);
663 }
664 if !(has_w && has_h) {
665 self.style_default(index, &[("aspectRatio", json!(w / h))]);
666 }
667 }
668
669 pub(crate) fn is_overlay(component: &str) -> bool {
670 OVERLAYS.contains(&component)
671 }
672
673 fn visible(&self, node: &UiNode, scope: &str) -> bool {
675 crate::data::is_visible(node.visible.as_ref(), &self.resolve_ctx(scope))
676 }
677
678 pub(crate) fn add(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
680 crate::deep(|| self.add_node(node, parent, layer, scope, force_hidden))
681 }
682
683 fn add_node(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
684 if !self.visible(node, scope) {
685 return None;
686 }
687 let view = self.ctx.view;
688 let def = view.component(&node.component);
689 let mut props = crate::data::resolve_node_props(def, &node.props, &self.resolve_ctx(scope), &view.defs);
693 if let Some(def) = def {
694 display_text_props(def, &mut props);
695 }
696 self.local_overrides(&node.id, &node.component, &mut props);
697 let form = parent.and_then(|p| self.nodes[p as usize].form.clone());
700 let mut form_disabled = false;
701 if let Some((disabled, busy)) = form.as_ref().and_then(|f| self.forms.get(f)).copied() {
702 if disabled && (FORM_FIELDS.contains(&node.component.as_str()) || node.component == "Button") {
703 form_disabled = !bool_prop(&props, "disabled");
704 props.insert("disabled".into(), Value::Bool(true));
705 }
706 if busy && node.component == "Button" && bool_prop(&props, "submit") {
707 props.insert("loading".into(), Value::Bool(true));
708 }
709 }
710 let ext = view.extension_of(&node.component).map(str::to_string);
711 let children_rule = def.map(|d| d.children.as_str()).unwrap_or("none");
712 let has_children = !node.children.is_empty() || node.template.is_some();
713 let component = if ext.is_some() { "Extension".to_string() } else { node.component.clone() };
714 let kind = if ext.is_some() {
715 if has_children {
716 NodeKind::Container
717 } else {
718 NodeKind::Leaf
719 }
720 } else {
721 match (node.component.as_str(), children_rule) {
722 ("Box", _) => NodeKind::Container,
723 (_, "none") => NodeKind::Leaf,
724 ("Button", "one") => {
725 if has_children {
726 NodeKind::Container
727 } else {
728 NodeKind::Leaf
729 }
730 }
731 _ => NodeKind::Container,
732 }
733 };
734 let mut n = self.blank(&node.id, &component, parent, layer, kind, scope);
735 n.hidden |= force_hidden;
736 if let Some(e) = ext {
737 n.catalog_id = Some(e);
738 n.extension_kind = Some(node.component.clone());
739 }
740 let mut base: Props = props.get("style").and_then(Value::as_object).cloned().unwrap_or_default();
743 if let Some(s) = &node.style {
744 for (k, v) in self.resolve_map(s, scope) {
745 base.insert(k, v);
746 }
747 }
748 n.base_style = base;
749 n.lines = props.get("lines").and_then(Value::as_u64).map(|l| l as u32);
750 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")))) && !bool_prop(&props, "disabled") && !bool_prop(&props, "loading");
751 n.on = node.on.clone();
752 n.source = self.sources.get(&node.id).cloned();
753 n.accessibility = node.accessibility.as_ref().and_then(|a| self.resolve(a, scope));
754 n.live = str_prop(&props, "live").filter(|l| *l != "off").map(str::to_string);
755 n.form_disabled = form_disabled;
756 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()));
757 if let Some(r) = &node.recipe {
758 n.part_query = Some(RecipeQuery::new(r.macro_.clone(), r.part.clone(), self.resolve_map(&r.props, scope), Vec::new()));
759 n.authored_keys = r.authored.clone();
760 }
761 if node.component == "Form" {
762 n.form = Some(node.id.clone());
763 }
764 n.props = props;
765 let index = self.push(n);
766 if node.component == "Button" && self.ctx.local.holds.get(&node.id).is_some_and(crate::hold::HoldTracker::active) {
768 self.add_state_own(index, "pressed");
769 }
770 if let Some(errors) = self.ctx.local.errors.get(&node.id) {
771 if !errors.messages.is_empty() {
772 let _ = errors;
773 self.add_state_own(index, "invalid");
774 }
775 }
776
777 let expand = self.ctx.expand_controls;
780 match node.component.as_str() {
782 "Image" | "Video" => self.media_ratio(index),
783 "ZoomableImage" => self.zoomable_box(index),
784 "Motion" => self.motion_box(index),
785 "Segmented" if crate::segmented::is_rail(&self.nodes[index as usize].props) => {
790 let tall = crate::segmented::rail_height(&self.nodes[index as usize].props, self.ctx.tab_bar);
791 self.style_default(index, &[("alignSelf", json!("flex-start")), ("flexShrink", json!(0)), ("height", json!(tall))])
792 }
793 "Segmented" if str_prop(&self.nodes[index as usize].props, "variant") == Some("bar") => {
797 self.style_default(index, &[("width", json!("100%"))])
798 }
799 "Segmented" if !bool_prop(&self.nodes[index as usize].props, "fill") && str_prop(&self.nodes[index as usize].props, "variant") != Some("bar") => {
801 self.style_default(index, &[("alignSelf", json!("flex-start"))])
802 }
803 "Button" if self.nodes[index as usize].kind == NodeKind::Container => {
807 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("center")), ("justifyContent", json!("center"))])
808 }
809 "VoiceWave" => self.style_default(index, &[("display", json!("flex")), ("alignItems", json!("center")), ("justifyContent", json!("center"))]),
811 "TreeGuides" => {
813 let depth = self.nodes[index as usize].props.get("depth").and_then(Value::as_f64).unwrap_or(0.0).max(0.0);
814 self.style_default(index, &[("alignSelf", json!("stretch")), ("width", json!(depth * crate::layout::TREE_GUIDE_COLUMN)), ("flexShrink", json!(0))]);
815 }
816 _ => {}
817 }
818 match node.component.as_str() {
819 "Tabs" => self.tabs(index, node, scope),
820 "Accordion" => self.accordion(index, node, scope),
821 "Carousel" => self.carousel(index, node, scope),
822 "List" => self.list(index, node, scope),
823 "Resizable" => self.resizable(index, node, scope),
824 "Table" => self.table(index, node, scope),
825 "Toast" => self.toast(index, node),
826 c if Self::is_overlay(c) => self.overlay(index, node, scope),
827 "Form" => self.form(index, node, scope),
828 "Input" | "Textarea" if expand => self.field(index),
829 "Select" if expand => self.select(index),
830 "DatePicker" | "DateRangePicker" if expand => self.date_picker(index),
831 "TimePicker" if expand => self.time_picker(index),
832 "NumberField" if expand => self.number_field(index),
833 "ChipInput" if expand => self.chip_input(index),
834 "FileUpload" if expand => self.file_upload(index),
835 "CodeBlock" if expand => self.code_block(index),
836 "Checkbox" | "Switch" if expand => self.check(index),
837 "Radio" if expand => self.radio(index),
838 "ChoiceTiles" if expand => self.choice_tiles(index),
839 "Slider" if expand => self.slider(index),
840 "SearchList" if expand => self.search_list(index),
841 "Skeleton" => self.skeleton_rows(index),
842 "Chart" => self.chart(index),
843 _ => {
844 if let Some(slots) = &node.slots {
845 for slot in slots.values() {
846 self.add(slot, Some(index), layer, scope, false);
847 }
848 }
849 self.children_of(index, node, scope, false);
850 }
851 }
852 Some(index)
853 }
854
855 fn add_state_own(&mut self, index: u32, state: &str) {
857 if let Some(q) = &mut self.nodes[index as usize].own_query {
858 if !q.states.iter().any(|s| s == state) {
859 q.states.push(state.to_string());
860 }
861 }
862 }
863
864 fn local_overrides(&self, id: &str, component: &str, props: &mut Props) {
867 let local = &self.ctx.local;
868 if let Some(v) = local.field_values.get(id) {
869 match component {
870 "DateRangePicker" => {
871 for k in ["start", "end"] {
872 if let Some(x) = v.get(k) {
873 props.insert(k.into(), x.clone());
874 }
875 }
876 }
877 _ => {
878 let key = match component {
879 "Checkbox" | "Switch" => "checked",
880 "ChipInput" => "values",
881 "FileUpload" => "files",
882 _ => "value",
883 };
884 props.insert(key.into(), v.clone());
885 }
886 }
887 }
888 if component == "Button" && local.holds.get(id).is_some_and(crate::hold::HoldTracker::active) {
890 if let Some(label) = props.get("holdingLabel").and_then(Value::as_str).filter(|l| !l.is_empty()).map(str::to_string) {
891 props.insert("label".into(), Value::String(label));
892 }
893 }
894 if component == "Toggle" {
895 if let Some(pressed) = local.pressed_toggles.get(id) {
896 props.insert("pressed".into(), Value::Bool(*pressed));
897 }
898 }
899 if component == "ChipInput" {
900 if let Some(chips) = local.chips.get(id) {
901 props.insert("values".into(), Value::Array(chips.iter().cloned().map(Value::String).collect()));
902 }
903 }
904 if component == "FileUpload" {
905 if let Some(files) = local.files.get(id) {
906 props.insert("files".into(), Value::Array(files.clone()));
907 }
908 }
909 if component == "Table" {
910 if let Some((key, dir)) = local.sort.get(id) {
911 props.insert("sort".into(), json!({"key": key, "direction": dir}));
912 }
913 if let Some(sel) = local.selected.get(id) {
914 props.insert("selected".into(), Value::Array(sel.iter().cloned().map(Value::String).collect()));
915 }
916 }
917 }
918
919 fn item_suffixes(&mut self, t: &crate::types::Template, base: &str) -> std::sync::Arc<Vec<String>> {
925 let cache_key = format!("{base}\u{0}{}", t.key.as_deref().unwrap_or(""));
926 if let Some(s) = self.suffixes.get(&cache_key) {
927 return s.clone();
928 }
929 let version = self.ctx.data_version;
930 if let Some((v, keys)) = self.ctx.local.key_cache.get(&format!("s\u{0}{cache_key}")) {
931 if *v == version {
932 let keys = keys.clone();
933 self.suffixes.insert(cache_key, keys.clone());
934 return keys;
935 }
936 }
937 let items = crate::data::get_pointer(self.ctx.data, base).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
938 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();
939 let out = std::sync::Arc::new(out);
940 self.ctx.local.key_cache.insert(format!("s\u{0}{cache_key}"), (version, out.clone()));
941 self.suffixes.insert(cache_key, out.clone());
942 out
943 }
944
945 pub(crate) fn enclosing_suffix(&self, node: &UiNode) -> String {
949 match self.sources.get(&node.id) {
950 Some(src) if node.id.len() > src.id.len() && node.id.starts_with(src.id.as_str()) => node.id[src.id.len()..].to_string(),
951 _ => String::new(),
952 }
953 }
954
955 pub(crate) fn children_of(&mut self, parent: u32, node: &UiNode, scope: &str, hidden: bool) -> Vec<u32> {
957 let mut out = Vec::new();
958 let layer = self.nodes[parent as usize].layer;
959 for child in &node.children {
960 out.extend(self.add(child, Some(parent), layer, scope, hidden));
961 }
962 if let Some(t) = &node.template {
963 let base = crate::data::absolute_path(&t.path, scope);
964 let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len).unwrap_or(0);
965 if let Some(tpl) = (self.ctx.template)(&t.component) {
966 let suffixes = self.item_suffixes(t, &base);
967 let enclosing = self.enclosing_suffix(node);
968 for i in 0..count {
969 if !self.take_template_item(&t.component, &tpl) {
970 break;
971 }
972 let item_scope = format!("{base}/{i}");
973 let suffix = format!("{enclosing}{}", suffixes[i]);
974 let item = self.instance(&tpl, &suffix, Some(&t.component));
975 out.extend(self.add(&item, Some(parent), layer, &item_scope, hidden));
976 }
977 }
978 }
979 out
980 }
981
982 fn take_template_item(&mut self, template: &str, tpl: &UiNode) -> bool {
990 if self.template_refused {
991 return false;
992 }
993 let nodes = subtree_nodes(tpl);
994 let message = if self.template_items >= crate::limits::MAX_TEMPLATE_ITEMS {
995 crate::limits::template_items_issue()
996 } else if self.template_nodes + nodes > crate::limits::MAX_COMPONENTS {
997 crate::limits::components_issue()
998 } else {
999 self.template_items += 1;
1000 self.template_nodes += nodes;
1001 return true;
1002 };
1003 self.template_refused = true;
1004 self.issues.push(crate::types::ReduceIssue { id: template.to_string(), message });
1005 false
1006 }
1007
1008 pub(crate) fn child_keys(&mut self, node: &UiNode, scope: &str) -> std::sync::Arc<Vec<String>> {
1012 if node.template.is_none() {
1013 if let Some(k) = self.ctx.local.static_keys.get(&node.id) {
1014 if k.len() == node.children.len() {
1015 return k.clone();
1016 }
1017 }
1018 let keys = std::sync::Arc::new(node.children.iter().map(|c| c.id.clone()).collect::<Vec<_>>());
1019 self.ctx.local.static_keys.insert(node.id.clone(), keys.clone());
1020 return keys;
1021 }
1022 let enclosing = self.enclosing_suffix(node);
1023 let cache_key = format!("c\u{0}{}\u{0}{scope}", node.id);
1024 if let Some((v, keys)) = self.ctx.local.key_cache.get(&cache_key) {
1025 if *v == self.ctx.data_version && keys.len() >= node.children.len() {
1026 return keys.clone();
1027 }
1028 }
1029 let mut keys: Vec<String> = node.children.iter().map(|c| c.id.clone()).collect();
1030 if let Some(t) = &node.template {
1031 let base = crate::data::absolute_path(&t.path, scope);
1032 for suffix in self.item_suffixes(t, &base).iter() {
1033 keys.push(format!("{}{enclosing}{suffix}", t.component));
1034 }
1035 }
1036 let keys = std::sync::Arc::new(keys);
1037 self.ctx.local.key_cache.insert(cache_key, (self.ctx.data_version, keys.clone()));
1038 keys
1039 }
1040
1041 pub(crate) fn one_child(&mut self, parent: u32, node: &UiNode, i: usize, scope: &str, hidden: bool) -> Option<u32> {
1043 let layer = self.nodes[parent as usize].layer;
1044 if let Some(child) = node.children.get(i) {
1045 return self.add(child, Some(parent), layer, scope, hidden);
1046 }
1047 let t = node.template.as_ref()?;
1048 let base = crate::data::absolute_path(&t.path, scope);
1049 let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len)?;
1050 let idx = i.checked_sub(node.children.len())?;
1051 if idx >= count {
1052 return None;
1053 }
1054 let tpl = (self.ctx.template)(&t.component)?;
1055 if !self.take_template_item(&t.component, &tpl) {
1056 return None;
1057 }
1058 let item_scope = format!("{base}/{idx}");
1059 let suffix = format!("{}{}", self.enclosing_suffix(node), self.item_suffixes(t, &base).get(idx).cloned().unwrap_or_else(|| format!(".{idx}")));
1060 let item = self.instance(&tpl, &suffix, Some(&t.component));
1061 self.add(&item, Some(parent), layer, &item_scope, hidden)
1062 }
1063
1064 pub(crate) fn responsive_prop(&mut self, props: &Props, key: &str) -> Option<Value> {
1066 let v = props.get(key)?;
1067 if crate::macros::is_responsive_value(v) {
1068 self.responsive = true;
1069 return crate::macros::responsive_at(v, self.ctx.breakpoint.as_deref()).cloned();
1070 }
1071 Some(v.clone())
1072 }
1073
1074 pub(crate) fn is_open(&self, id: &str, props: &Props, default: bool) -> bool {
1076 self.ctx.local.open.get(id).copied().unwrap_or_else(|| props.get("open").and_then(Value::as_bool).unwrap_or(default))
1077 }
1078
1079 pub(crate) fn layer_root(&mut self, owner: &LNode, part: &str, style: Value) -> (u32, u32) {
1081 let layer = self.next_layer;
1082 self.next_layer += 1;
1083 let mut root = self.blank(&format!("{}.{part}", owner.id), "Box", None, layer, NodeKind::Container, &owner.scope);
1084 root.part = Some(part.to_string());
1085 root.owner = Some(owner.id.clone());
1086 root.owner_component = Some(owner.component.clone());
1087 root.form = owner.form.clone();
1088 root.base_style = obj(style);
1089 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()]));
1090 let index = self.push(root);
1091 (layer, index)
1092 }
1093
1094 #[allow(clippy::too_many_arguments)]
1095 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>) {
1096 let class = if matches!(placement, LayerPlacement::Toast { .. }) { LayerClass::Toast } else { LayerClass::Overlay };
1097 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 });
1098 }
1099}
1100
1101pub fn row_scope_root(scope: &str) -> Option<&str> {
1104 let rest = scope.strip_prefix("/$row/")?;
1105 let mut end = "/$row/".len();
1106 for (k, part) in rest.splitn(3, '/').enumerate().take(2) {
1107 end += part.len() + usize::from(k == 0);
1108 }
1109 Some(&scope[..end])
1110}
1111
1112pub fn builtin_icon(slot: &str) -> Option<&'static str> {
1114 let g = crate::generated::catalog::BUILTIN_ICON_SLOTS.iter().position(|s| *s == slot)?;
1115 crate::generated::catalog::BUILTIN_ICON_NAMES.get(g).copied()
1116}
1117
1118pub fn build(root: &UiNode, ctx: &mut BuildContext) -> Built {
1121 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() };
1122 if b.add(root, None, 0, "", false).is_none() {
1123 let mut n = b.blank(&root.id, "Box", None, 0, NodeKind::Container, "");
1125 n.hidden = true;
1126 b.push(n);
1127 }
1128 b.flush_toasts();
1129 b.layers.sort_by_key(|l| (l.class == LayerClass::Toast, l.layer));
1132 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 }
1133}
1134
1135pub fn build_subtree(node: &UiNode, parent: &LNode, scope: &str, seed: BuildSeed, ctx: &mut BuildContext) -> Built {
1141 let mut stub = parent.clone();
1142 stub.index = 0;
1143 stub.parent = None;
1144 stub.children.clear();
1145 let layer = stub.layer;
1146 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() };
1147 b.add(node, Some(0), layer, scope, false);
1148 b.flush_toasts();
1149 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 }
1150}
1151
1152pub fn motion_size(props: &Props) -> Option<(f64, f64)> {
1155 let pos = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1156 let size = props.get("size").and_then(Value::as_array);
1157 let from_size = size.and_then(|a| Some((pos(a.first())?, pos(a.get(1))?)));
1158 from_size.or_else(|| {
1159 let l = props.get("lottie")?;
1160 Some((pos(l.get("w"))?, pos(l.get("h"))?))
1161 })
1162}
1163
1164pub fn zoomable_aspect_ratio(props: &Props) -> f64 {
1169 let num = |k: &str| props.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1170 match (num("naturalWidth"), num("naturalHeight")) {
1171 (Some(w), Some(h)) if crate::zoom::zoom_is_tall(crate::zoom::ZoomSize { width: w, height: h }) => 1.0,
1172 (Some(w), Some(h)) => w / h,
1173 _ => crate::layout::MEDIA_ASPECT_RATIO,
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 #[test]
1182 fn builtin_glyphs_resolve() {
1183 assert_eq!(builtin_icon("CodeBlock.copy"), Some("ui-copy"));
1184 assert_eq!(builtin_icon("Table.sortIcon.asc"), Some("ui-chevron-up"));
1185 assert_eq!(builtin_icon("Nope.x"), None);
1186 }
1187
1188 #[test]
1189 fn a_zoomable_box_follows_its_picture() {
1190 let p = |v: Value| v.as_object().cloned().unwrap_or_default();
1191 assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 1440, "naturalHeight": 900}))) - 1.6).abs() < 1e-9);
1192 assert_eq!(zoomable_aspect_ratio(&p(json!({"naturalWidth": 1000, "naturalHeight": 8000}))), 1.0);
1194 assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 600, "naturalHeight": 900}))) - 600.0 / 900.0).abs() < 1e-9);
1195 assert_eq!(zoomable_aspect_ratio(&p(json!({}))), crate::layout::MEDIA_ASPECT_RATIO);
1196 }
1197}