Skip to main content

eui_core/layout_tree/
mod.rs

1//! The LAYOUT tree: the normalized `UiNode` tree (macros already expanded)
2//! becomes a flat pre-order list of layout nodes the engine and the painters
3//! work on. Here the core adds what painters must not invent: the BIND pass
4//! (bindings, calls and `$string.<id>` copy resolved in props, styles and
5//! recipe props; a node whose `visible` resolves falsy is dropped with its
6//! subtree), template children instantiated per item (ids `<template>.<key>`
7//! with the template `key`, else `<template>.<i>`), natives with structure
8//! of their own get synthetic PART nodes (ids `<owner>.<part>`, recipes keyed
9//! on the owner's component so a theme styles them), overlay natives and
10//! every popup (Select, the pickers, menus and submenus, tooltips, hover
11//! cards, toasts) put their content into LAYERS with their own roots, and
12//! extension natives arrive as `Extension` leaves.
13//!
14//! The builder numbers nodes in pre-order; the surface maps them onto
15//! STABLE SLOTS by id afterwards (`surface::reconcile`), so a re-windowed
16//! list or an opened overlay keeps every other node's index.
17
18mod 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/// One layout node. `style` keeps tokens UNRESOLVED and conditions NESTED;
49/// the surface resolves them per theme/mode and flattens per pass.
50#[derive(Debug, Clone, PartialEq)]
51pub struct LNode {
52    /// The SLOT (stable across rebuilds by id) once the surface reconciled;
53    /// the pre-order position inside a fresh `Built`.
54    pub index: u32,
55    pub id: String,
56    /// The native kind painted (`Box`, `Text`, `Button`, …, `Extension`).
57    pub component: String,
58    /// The synthetic part of a native (`Tabs/tab`, `Input/label`).
59    pub part: Option<String>,
60    /// The id of the native that owns the part.
61    pub owner: Option<String>,
62    pub owner_component: Option<String>,
63    /// For `Extension` nodes: the extension's catalog id and native kind.
64    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    /// Props with bindings, calls and `$string.<id>` resolved.
72    pub props: Props,
73    /// The structural style (author's + template's), bindings resolved,
74    /// token refs NOT resolved, conditions nested.
75    pub base_style: Props,
76    /// The keys of `base_style` a builder only DEFAULTED (`style_default`):
77    /// the native's own root recipe wins over them (an author's key wins
78    /// over the recipe).
79    pub default_keys: Vec<String>,
80    /// The native's own root recipe (Text/root for its variant).
81    pub own_query: Option<RecipeQuery>,
82    /// The macro or native PART recipe (Badge/label, Tabs/tab).
83    pub part_query: Option<RecipeQuery>,
84    /// VAPP-163: the `base_style` keys the author wrote ON the macro node
85    /// (`recipe.authored`): they win over the part recipe.
86    pub authored_keys: Vec<String>,
87    pub hidden: bool,
88    pub lines: Option<u32>,
89    pub pressable: bool,
90    /// The data scope (template item pointer) bindings resolved against.
91    pub scope: String,
92    /// Where the node's SOURCE `UiNode` lives when its id is an instance id
93    /// (a template item's descendants, a Table slot cell's controls carry
94    /// `<sourceId><suffix>`). `None` = the id itself names a node of the
95    /// surface's tree (or the node is a synthetic part).
96    pub source: Option<SourceRef>,
97    /// The `on` map of the source node (events route through the owner).
98    pub on: Option<IndexMap<String, Value>>,
99    pub accessibility: Option<Value>,
100    /// The overlay node this node opens (a trigger slot's root).
101    pub trigger_for: Option<String>,
102    /// The enclosing Form's id (fields and submit buttons).
103    pub form: Option<String>,
104    /// EXP-1653: `disabled` came from the enclosing Form, not the author.
105    /// Behaviour sees it; recipe PROP conditions (`when: {disabled: true}`)
106    /// do not, like web, where only the `disabled` state reaches the parts.
107    pub form_disabled: bool,
108    /// A live region: `polite | assertive` (Text `live`, Toast, Form errors).
109    pub live: Option<String>,
110}
111
112/// The source of an instance node: the source node's id and the template
113/// component whose reduced tree holds it (`None` = the surface's tree).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SourceRef {
116    pub id: String,
117    pub template: Option<String>,
118}
119
120/// How an overlay layer is placed.
121#[derive(Debug, Clone, PartialEq)]
122pub enum LayerPlacement {
123    /// Centred in the viewport (Dialog).
124    Centered,
125    /// Pinned to one viewport edge (Drawer).
126    Edge(OverlaySide),
127    /// Against the anchor node's frame (Popover, Tooltip, menus, pickers).
128    Anchored { anchor: u32, side: OverlaySide, align: OverlayAlign },
129    /// Against a point (a context Menu opened at the pointer).
130    AtPoint { x: f32, y: f32 },
131    /// The toast stack (bottom-centre on phones, bottom-end from `md`),
132    /// `order` 0 = nearest the edge.
133    Toast { order: u32 },
134}
135
136/// Which band a layer stacks in: base < overlay < toast.
137#[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    /// Dialog/Drawer: a scrim under it, the focus trapped inside.
153    pub modal: bool,
154    /// Escape / a scrim press / a drag closes it.
155    pub dismissible: bool,
156    /// The popup is at least as wide as its anchor (Select, pickers).
157    pub match_anchor_width: bool,
158    /// The layer's body that scrolls when the content is taller than the
159    /// viewport (Dialog/Drawer `body`, a popup list).
160    pub scroll_body: Option<u32>,
161    /// The side is LOGICAL (a submenu opens at the inline end): right in
162    /// LTR, left in RTL.
163    pub mirror: bool,
164}
165
166/// A windowed list's state for one build.
167#[derive(Debug, Clone, PartialEq)]
168pub struct ListSpec {
169    pub node: u32,
170    /// The scroll key (the List's or the Table's id).
171    pub id: String,
172    pub keys: std::sync::Arc<Vec<String>>,
173    pub range: VisibleRange,
174    pub offsets: std::sync::Arc<Vec<f32>>,
175    /// The content extent on the list's axis (height, or width when
176    /// `horizontal`).
177    pub content_height: f32,
178    pub windowed: bool,
179    /// Round 2: a horizontal List windows on x.
180    pub horizontal: bool,
181    /// The gap the offsets use (a `divided` list adds the hairline).
182    pub gap: f32,
183    /// The DATA index of each row position (`scrollToIndex` takes data
184    /// indices): `None` = position i is item i (minus static children).
185    pub data_index: Option<std::sync::Arc<Vec<Option<usize>>>>,
186    /// Row positions of the section headers (ascending).
187    pub headers: std::sync::Arc<Vec<usize>>,
188    /// `stickyHeaders`: the header pinned at the scroll offset.
189    pub sticky: bool,
190    /// Static children before the template items (List).
191    pub static_count: usize,
192}
193
194/// Round 2 (§5): where a windowed list's viewport comes from.
195#[derive(Debug, Clone, PartialEq)]
196pub enum ListViewSource {
197    /// The list scrolls itself (bounded and taller content).
198    Own,
199    /// Its nearest scrolling ancestor `id`; `rel` = the list's content
200    /// start inside that ancestor's content.
201    Ancestor { id: String, rel: f32 },
202    /// The host viewport (`Surface::set_surface_scroll`); `rel` = the
203    /// list's content start in the surface.
204    Host { rel: f32 },
205}
206
207/// A list's viewport on its axis, recorded after each pass.
208#[derive(Debug, Clone, PartialEq)]
209pub struct ListView {
210    pub source: ListViewSource,
211    pub viewport: f32,
212}
213
214/// A form field's validation state after a submit: its failed messages.
215#[derive(Debug, Clone, Default, PartialEq)]
216pub struct FieldErrors {
217    pub name: String,
218    pub messages: Vec<String>,
219}
220
221/// Local UI state the core owns (never sent to the producer unless bound).
222#[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    /// VAPP-162: Button `hold` gestures per button id (`Surface::hold`).
230    pub holds: HashMap<String, crate::hold::HoldTracker>,
231    pub lists: HashMap<String, ListWindow>,
232    /// Any scroll container's offset by node id (x, y).
233    pub scroll: HashMap<String, (f32, f32)>,
234    /// A searchable Select's query / a ChipInput's typed text.
235    pub query: HashMap<String, String>,
236    /// A calendar's visible month (year, month 1–12).
237    pub month: HashMap<String, (i32, u32)>,
238    /// A DateRangePicker's first pick, waiting for the second.
239    pub range_anchor: HashMap<String, String>,
240    /// The open submenu of a menu (item index).
241    pub submenu: HashMap<String, usize>,
242    /// EXP-1259: the highlighted row of a SearchList (by its id) or of a
243    /// searchable submenu (by its `search` part's id), a position in the
244    /// FILTERED rows.
245    pub active: HashMap<String, usize>,
246    /// Where a context Menu was opened (surface coordinates).
247    pub context_point: HashMap<String, (f32, f32)>,
248    /// The host's latest value of a field (text inputs are host-owned).
249    pub field_values: HashMap<String, Value>,
250    /// Failed checks per field id, set by a refused submit.
251    pub errors: HashMap<String, FieldErrors>,
252    /// Unbound Table sort and selection.
253    pub sort: HashMap<String, (String, String)>,
254    pub selected: HashMap<String, Vec<String>>,
255    /// Unbound ChipInput values and FileUpload files.
256    pub chips: HashMap<String, Vec<String>>,
257    pub files: HashMap<String, Vec<Value>>,
258    /// CodeBlocks whose copy button shows `copied`.
259    pub copied: HashMap<String, bool>,
260    /// The child keys of a container with STATIC children (per reduce).
261    pub static_keys: HashMap<String, std::sync::Arc<Vec<String>>>,
262    /// Round 2: each windowed list's viewport (own, ancestor or host).
263    pub list_views: HashMap<String, ListView>,
264    /// The host's scroll offset of the whole surface (x, y); `None` = the
265    /// host never set one (a page-scrolled list then takes the offset the
266    /// host gives the list itself, `Surface::scroll`).
267    pub surface_scroll: Option<(f32, f32)>,
268    /// Resizable sizes moved by a drag or a key (unbound groups, and the
269    /// live sizes during a drag).
270    pub sizes: HashMap<String, Vec<f64>>,
271    /// Resizable drags in progress: handle id → the sizes at the START.
272    pub drags: HashMap<String, Vec<f64>>,
273    /// The last pass placed a `position: sticky` node (a scroll lays out again).
274    pub sticky_any: bool,
275    /// Template item keys and child keys per (array, key) / (node, scope),
276    /// valid while the data model's version holds: a scroll step over a
277    /// 100,000-item list never re-keys it.
278    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    /// Reduce a template component by id into a normalized subtree.
287    pub template: &'a dyn Fn(&str) -> Option<UiNode>,
288    pub viewport_height: f32,
289    /// Round 2: the viewport width (horizontal windows).
290    pub viewport_width: f32,
291    /// `$control.hairline` (Resizable handles, list dividers) and
292    /// `$control.row` (the extent of an unmeasured list item).
293    pub hairline: f32,
294    pub row_extent: f32,
295    /// EXP-1259: `$control.buttonSm`, a menu row's height (a searchable
296    /// submenu's list window).
297    pub menu_row: f32,
298    /// VAPP-162: `$control.tabBar`, the item height of a vertical `bar`
299    /// Segmented (the navigation rail).
300    pub tab_bar: f32,
301    /// EXP-1548: `$control.checkbox`, the width of a Table's selection
302    /// column (part of the table's minimum width).
303    pub checkbox: f32,
304    /// The theme's default gap for `List` items per `gap` enum, in px.
305    pub gap_px: &'a dyn Fn(&str) -> f32,
306    /// Expand form controls into label/field/description parts (a themed
307    /// surface); off = geometry mode, every native is ONE measured leaf.
308    pub expand_controls: bool,
309    /// The surface's built-in string table.
310    pub strings: &'a StringTable,
311    /// The active breakpoint (`None` = base) for responsive native props.
312    pub breakpoint: Option<String>,
313    /// The surface locale (week start).
314    pub locale: &'a str,
315    /// The surface is at least `md` wide (the toast stack sits bottom-end).
316    pub wide: bool,
317    /// Today as `yyyy-mm-dd` (the host's clock; calendars open on it).
318    pub today: Option<String>,
319    /// Round 2: the surface formatter (Table cells, NumberField, chart
320    /// ticks, pickers, calendar names, the format functions).
321    pub formatter: &'a dyn crate::format::Formatter,
322    /// The clock (epoch ms) relative times read.
323    pub now: f64,
324    /// Bumped on every write to the data model (the key caches' version).
325    pub data_version: u64,
326    /// VAPP-200: a read-only surface: every writing control builds as
327    /// `disabled: true` ([`crate::read_only::disables`]).
328    pub read_only: bool,
329}
330
331pub struct Built {
332    pub nodes: Vec<LNode>,
333    pub layers: Vec<LayerSpec>,
334    pub lists: Vec<ListSpec>,
335    /// A native read a responsive prop: a breakpoint change rebuilds.
336    pub responsive: bool,
337    /// Text whose live region announces when it changes: id → (text, live).
338    pub toasts: Vec<ToastSpec>,
339    /// Literal Table rows as data scopes (`/$row/<table>/<i>` → the row):
340    /// events resolve against them after the build.
341    pub row_scopes: HashMap<String, Value>,
342    /// Form id → (disabled, busy), seeded into a later subtree rebuild.
343    pub forms: HashMap<String, (bool, bool)>,
344    /// VAPP-103: what the build refused (template items past the limit).
345    pub issues: Vec<crate::types::ReduceIssue>,
346}
347
348/// What a SUBTREE rebuild inherits from the full build: the enclosing
349/// Forms' flags and the literal Table rows.
350#[derive(Debug, Clone, Default)]
351pub struct BuildSeed {
352    pub row_scopes: HashMap<String, Value>,
353    pub forms: HashMap<String, (bool, bool)>,
354}
355
356/// An open Toast the host times (`duration` 0 = sticky).
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct ToastSpec {
359    pub id: String,
360    pub duration_ms: f64,
361    #[serde(rename = "type")]
362    pub kind: String,
363}
364
365pub(crate) struct Builder<'a, 'b> {
366    pub(crate) ctx: &'b mut BuildContext<'a>,
367    pub(crate) nodes: Vec<LNode>,
368    pub(crate) layers: Vec<LayerSpec>,
369    pub(crate) lists: Vec<ListSpec>,
370    pub(crate) responsive: bool,
371    pub(crate) toasts: Vec<ToastSpec>,
372    /// Toast nodes waiting for the toast band (built after every overlay).
373    pub(crate) pending_toasts: Vec<(u32, UiNode)>,
374    /// The next layer number (layers nest: a submenu opens inside a menu).
375    pub(crate) next_layer: u32,
376    /// Literal Table rows as data scopes (`/$row/<table>/<i>` → the row).
377    pub(crate) row_scopes: HashMap<String, Value>,
378    /// Form id → (disabled, busy), for the fields and buttons inside.
379    pub(crate) forms: HashMap<String, (bool, bool)>,
380    /// Instance id → its source (filled by [`Builder::instance`]).
381    pub(crate) sources: HashMap<String, SourceRef>,
382    /// Template item suffixes per array (and key) for this build.
383    pub(crate) suffixes: HashMap<String, std::sync::Arc<Vec<String>>>,
384    /// The flex gap of each windowed container (its spacers subtract it).
385    pub(crate) flex_gaps: HashMap<u32, f32>,
386    /// VAPP-103: template items this build instantiated (`maxTemplateItems`).
387    pub(crate) template_items: usize,
388    /// VAPP-103 rfix: the nodes this build holds (`maxComponents`): the
389    /// reduced tree's, plus every instantiated item's template subtree (a
390    /// 500-node row × 1000 items never builds).
391    pub(crate) template_nodes: usize,
392    /// An item was refused: every later one is too (the rest).
393    pub(crate) template_refused: bool,
394    /// What the build refused (template items past the limit).
395    pub(crate) issues: Vec<crate::types::ReduceIssue>,
396}
397
398/// The nodes of a template subtree (children and slots; iterative).
399pub(crate) fn subtree_nodes(root: &UiNode) -> usize {
400    let mut count = 0;
401    let mut stack = vec![root];
402    while let Some(n) = stack.pop() {
403        count += 1;
404        stack.extend(n.children.iter());
405        stack.extend(n.slots.iter().flat_map(|s| s.values()));
406    }
407    count
408}
409
410pub(crate) fn obj(v: Value) -> Props {
411    match v {
412        Value::Object(m) => m,
413        _ => Map::new(),
414    }
415}
416
417fn with_suffix(node: &UiNode, suffix: &str, template: Option<&str>, sources: &mut HashMap<String, SourceRef>) -> UiNode {
418    let mut out = node.clone();
419    out.id = format!("{}{suffix}", node.id);
420    // An id that is already an instance keeps ITS source (a Table slot
421    // inside a template row); an original id is its own source.
422    let source = sources.get(&node.id).cloned().unwrap_or_else(|| SourceRef { id: node.id.clone(), template: template.map(str::to_string) });
423    sources.insert(out.id.clone(), source);
424    out.children = node.children.iter().map(|c| with_suffix(c, suffix, template, sources)).collect();
425    if let Some(slots) = &node.slots {
426        out.slots = Some(slots.iter().map(|(k, v)| (k.clone(), with_suffix(v, suffix, template, sources))).collect());
427    }
428    out
429}
430
431/// The overlay natives whose content lives in a layer.
432pub const OVERLAYS: &[&str] = &["Dialog", "Drawer", "Popover", "Tooltip", "Menu"];
433
434/// The form fields a Form collects (when they carry a `name`).
435pub const FORM_FIELDS: &[&str] =
436    &["Input", "Textarea", "NumberField", "Checkbox", "Radio", "ChoiceTiles", "Switch", "Select", "ChipInput", "DatePicker", "DateRangePicker", "TimePicker", "FileUpload", "Slider"];
437
438/// Text props (`string`/`markdown` schemas) bound to a number or a boolean
439/// show its [`crate::format::display_string`] (`412`, `true`): painters read
440/// strings.
441pub(crate) fn display_text_props(def: &crate::types::ComponentDef, props: &mut Props) {
442    for (k, v) in props.iter_mut() {
443        if matches!(v, Value::Number(_) | Value::Bool(_)) && def.props.get(k).is_some_and(|s| s.type_ == "string" || s.type_ == "markdown") {
444            *v = Value::String(crate::format::display_string(v));
445        }
446    }
447}
448
449pub(crate) fn str_prop<'p>(props: &'p Props, key: &str) -> Option<&'p str> {
450    props.get(key).and_then(Value::as_str)
451}
452
453/// The props a recipe's `when` reads: the author's, without a `disabled`
454/// only the enclosing Form injected (EXP-1653).
455pub(crate) fn recipe_props(props: &Props, form_disabled: bool) -> std::borrow::Cow<'_, Props> {
456    if !form_disabled {
457        return std::borrow::Cow::Borrowed(props);
458    }
459    let mut own = props.clone();
460    own.remove("disabled");
461    std::borrow::Cow::Owned(own)
462}
463
464pub(crate) fn bool_prop(props: &Props, key: &str) -> bool {
465    props.get(key).and_then(Value::as_bool).unwrap_or(false)
466}
467
468pub(crate) fn js(v: &Value) -> String {
469    crate::json::to_js_string(v)
470}
471
472impl<'a, 'b> Builder<'a, 'b> {
473    pub(crate) fn resolve_ctx<'s>(&'s self, scope: &'s str) -> ResolveContext<'s> {
474        let ctx = ResolveContext::new(self.ctx.data, scope).with_strings(self.ctx.strings).with_formatter(self.ctx.formatter).with_now(self.ctx.now);
475        match row_scope_root(scope).and_then(|root| self.row_scopes.get_key_value(root)) {
476            Some((root, row)) => ctx.with_overlay(root, row),
477            None => ctx,
478        }
479    }
480
481    pub(crate) fn resolve(&self, value: &Value, scope: &str) -> Option<Value> {
482        resolve_value(value, &self.resolve_ctx(scope))
483    }
484
485    pub(crate) fn resolve_map(&self, map: &Props, scope: &str) -> Props {
486        obj(resolve_value(&Value::Object(map.clone()), &self.resolve_ctx(scope)).unwrap_or(Value::Null))
487    }
488
489    /// A built-in string by id.
490    pub(crate) fn string(&self, id: &str) -> String {
491        self.ctx.strings.get(id).cloned().unwrap_or_else(|| id.to_string())
492    }
493
494    /// A built-in string with its `{name}` placeholders filled
495    /// (`strings::format_string`), e.g. `removeItem` = "Remove {name}".
496    pub(crate) fn string_with(&self, id: &str, params: &[(&str, &str)]) -> String {
497        let params: Map<String, Value> = params.iter().map(|(k, v)| (k.to_string(), Value::String(v.to_string()))).collect();
498        crate::strings::format_string(&self.string(id), &params)
499    }
500
501    /// An INSTANCE of `node` (every id + `suffix`), its sources recorded:
502    /// `template` = the template component the original ids belong to
503    /// (`None` = the surface's tree).
504    pub(crate) fn instance(&mut self, node: &UiNode, suffix: &str, template: Option<&str>) -> UiNode {
505        with_suffix(node, suffix, template, &mut self.sources)
506    }
507
508    pub(crate) fn push(&mut self, mut node: LNode) -> u32 {
509        let index = self.nodes.len() as u32;
510        node.index = index;
511        if let Some(p) = node.parent {
512            self.nodes[p as usize].children.push(index);
513        }
514        self.nodes.push(node);
515        index
516    }
517
518    pub(crate) fn blank(&self, id: &str, component: &str, parent: Option<u32>, layer: u32, kind: NodeKind, scope: &str) -> LNode {
519        let (depth, hidden, form) = parent
520            .map(|p| {
521                let pn = &self.nodes[p as usize];
522                (pn.depth + 1, pn.hidden, pn.form.clone())
523            })
524            .unwrap_or((0, false, None));
525        LNode {
526            index: 0,
527            id: id.to_string(),
528            component: component.to_string(),
529            part: None,
530            owner: None,
531            owner_component: None,
532            catalog_id: None,
533            extension_kind: None,
534            depth,
535            parent,
536            children: Vec::new(),
537            layer,
538            kind,
539            props: Map::new(),
540            base_style: Map::new(),
541            default_keys: Vec::new(),
542            own_query: None,
543            part_query: None,
544            authored_keys: Vec::new(),
545            hidden,
546            lines: None,
547            pressable: false,
548            scope: scope.to_string(),
549            source: None,
550            on: None,
551            accessibility: None,
552            trigger_for: None,
553            form,
554            form_disabled: false,
555            live: None,
556        }
557    }
558
559    /// A synthetic part of `owner` under `parent`, id `<owner>.<part><suffix>`,
560    /// with the owner's recipe for that part.
561    #[allow(clippy::too_many_arguments)]
562    pub(crate) fn part_in(&mut self, parent: u32, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value, suffix: &str) -> u32 {
563        let id = format!("{}.{part}{suffix}", owner.id);
564        let layer = self.nodes[parent as usize].layer;
565        let mut n = self.blank(&id, component, Some(parent), layer, kind, &owner.scope);
566        n.part = Some(part.to_string());
567        n.owner = Some(owner.id.clone());
568        n.owner_component = Some(owner.component.clone());
569        n.base_style = obj(style);
570        n.props = obj(props);
571        let query_props = self.ctx.recipes.native_recipe_props(&owner.component, &recipe_props(&owner.props, owner.form_disabled));
572        n.part_query = Some(RecipeQuery::new(owner.component.clone(), part, query_props, Vec::new()));
573        if let Some(l) = n.props.get("lines").and_then(Value::as_u64) {
574            n.lines = Some(l as u32);
575        }
576        self.push(n)
577    }
578
579    /// [`Self::part_in`] directly under the owner.
580    pub(crate) fn part(&mut self, owner: &LNode, part: &str, component: &str, kind: NodeKind, style: Value, props: Value) -> u32 {
581        self.part_in(owner.index, owner, part, component, kind, style, props, "")
582    }
583
584    pub(crate) fn text_part(&mut self, parent: u32, owner: &LNode, part: &str, text: &str, variant: &str, suffix: &str) -> u32 {
585        self.part_in(parent, owner, part, "Text", NodeKind::Leaf, json!({}), json!({"text": text, "variant": variant}), suffix)
586    }
587
588    /// An icon leaf part with the builtin glyph of `<Owner>.<slot>`.
589    pub(crate) fn icon_part(&mut self, parent: u32, owner: &LNode, part: &str, glyph_slot: &str, suffix: &str) -> u32 {
590        let name = builtin_icon(glyph_slot).unwrap_or("ui-icon-placeholder");
591        self.part_in(parent, owner, part, "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": name, "size": "sm"}), suffix)
592    }
593
594    /// Round 2: an accessibility key the author did not set (a built-in
595    /// label such as `$string.dialog` on a title-less Dialog).
596    pub(crate) fn default_a11y(&mut self, index: u32, key: &str, value: Value) {
597        let a = self.nodes[index as usize].accessibility.get_or_insert_with(|| json!({}));
598        if let Some(m) = a.as_object_mut() {
599            m.entry(key.to_string()).or_insert(value);
600        }
601    }
602
603    /// Add a state to a node's part query (`selected`, `open`, `checked`…).
604    pub(crate) fn add_state(&mut self, index: u32, state: &str) {
605        if let Some(q) = &mut self.nodes[index as usize].part_query {
606            if !q.states.iter().any(|s| s == state) {
607                q.states.push(state.to_string());
608            }
609        }
610    }
611
612    /// Set a recipe prop on a node's part query (a part-level discriminator).
613    pub(crate) fn query_prop(&mut self, index: u32, key: &str, value: Value) {
614        if let Some(q) = &mut self.nodes[index as usize].part_query {
615            q.props.insert(key.to_string(), value);
616        }
617    }
618
619    pub(crate) fn style_default(&mut self, index: u32, entries: &[(&str, Value)]) {
620        let n = &mut self.nodes[index as usize];
621        for (k, v) in entries {
622            if !n.base_style.contains_key(*k) {
623                n.base_style.insert(k.to_string(), v.clone());
624                n.default_keys.push(k.to_string());
625            }
626        }
627    }
628
629    /// Image / Video (round 2 §7): the box takes `aspectRatio` (a Video
630    /// always, an Image without a `height`; default `mediaAspectRatio`), so
631    /// height = width / ratio before and after the media loads.
632    fn media_ratio(&mut self, index: u32) {
633        let n = &self.nodes[index as usize];
634        let given = n.props.get("aspectRatio").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0);
635        let has_height = n.props.get("height").is_some_and(|v| !v.is_null()) || n.base_style.contains_key("height");
636        let ratio = given.or_else(|| (n.component == "Video" || !has_height).then_some(crate::layout::MEDIA_ASPECT_RATIO));
637        if let Some(r) = ratio {
638            self.style_default(index, &[("aspectRatio", json!(r))]);
639        }
640    }
641
642    /// EXP-1586: THE ZoomableImage box (catalog/style.json `zoomableImage`):
643    /// as wide as its parent; without a style `height` it takes
644    /// [`zoomable_aspect_ratio`].
645    fn zoomable_box(&mut self, index: u32) {
646        let n = &self.nodes[index as usize];
647        let has_height = n.base_style.contains_key("height");
648        let ratio = zoomable_aspect_ratio(&n.props);
649        self.style_default(index, &[("width", json!("100%"))]);
650        if !has_height {
651            self.style_default(index, &[("aspectRatio", json!(ratio))]);
652        }
653    }
654
655    /// EXP-1604: THE Motion box (eui-motion README "Painting a Motion" §4):
656    /// unstyled, the composition's px size, shrinking to the available
657    /// width with its aspect kept (`width: W`, `maxWidth: 100%`,
658    /// `aspectRatio: W/H`). A style `width` / `height` wins; a `src`
659    /// animation (size unknown until fetched) gets no default.
660    fn motion_box(&mut self, index: u32) {
661        let n = &self.nodes[index as usize];
662        let Some((w, h)) = motion_size(&n.props) else { return };
663        let (has_w, has_h) = (n.base_style.contains_key("width"), n.base_style.contains_key("height"));
664        if !has_w && !has_h {
665            self.style_default(index, &[("width", json!(w)), ("maxWidth", json!("100%"))]);
666        }
667        if !(has_w && has_h) {
668            self.style_default(index, &[("aspectRatio", json!(w / h))]);
669        }
670    }
671
672    pub(crate) fn is_overlay(component: &str) -> bool {
673        OVERLAYS.contains(&component)
674    }
675
676    /// Is a node visible under the data model (`visible` absent = yes)?
677    fn visible(&self, node: &UiNode, scope: &str) -> bool {
678        crate::data::is_visible(node.visible.as_ref(), &self.resolve_ctx(scope))
679    }
680
681    /// Add `node` under `parent`; `None` when its `visible` is falsy.
682    pub(crate) fn add(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
683        crate::deep(|| self.add_node(node, parent, layer, scope, force_hidden))
684    }
685
686    fn add_node(&mut self, node: &UiNode, parent: Option<u32>, layer: u32, scope: &str, force_hidden: bool) -> Option<u32> {
687        if !self.visible(node, scope) {
688            return None;
689        }
690        let view = self.ctx.view;
691        let def = view.component(&node.component);
692        // Round 2: props resolve ALONG THEIR SCHEMA (a literal Table `rows`
693        // holding `{path}` is data), then a bound number or boolean in a
694        // text prop shows as its display string.
695        let mut props = crate::data::resolve_node_props(def, &node.props, &self.resolve_ctx(scope), &view.defs);
696        if let Some(def) = def {
697            display_text_props(def, &mut props);
698        }
699        self.local_overrides(&node.id, &node.component, &mut props);
700        // VAPP-200: a read-only surface disables its writing controls as if
701        // their author had (before the Form cascade: a disabled Form
702        // disables what is inside).
703        if self.ctx.read_only && crate::read_only::disables(&node.component, node.on.as_ref()) {
704            props.insert("disabled".into(), Value::Bool(true));
705        }
706        // Inside a disabled Form every field (and button) is inert; inside a
707        // busy one the submit buttons show loading.
708        let form = parent.and_then(|p| self.nodes[p as usize].form.clone());
709        let mut form_disabled = false;
710        if let Some((disabled, busy)) = form.as_ref().and_then(|f| self.forms.get(f)).copied() {
711            if disabled && (FORM_FIELDS.contains(&node.component.as_str()) || node.component == "Button") {
712                form_disabled = !bool_prop(&props, "disabled");
713                props.insert("disabled".into(), Value::Bool(true));
714            }
715            if busy && node.component == "Button" && bool_prop(&props, "submit") {
716                props.insert("loading".into(), Value::Bool(true));
717            }
718        }
719        let ext = view.extension_of(&node.component).map(str::to_string);
720        let children_rule = def.map(|d| d.children.as_str()).unwrap_or("none");
721        let has_children = !node.children.is_empty() || node.template.is_some();
722        let component = if ext.is_some() { "Extension".to_string() } else { node.component.clone() };
723        let kind = if ext.is_some() {
724            if has_children {
725                NodeKind::Container
726            } else {
727                NodeKind::Leaf
728            }
729        } else {
730            match (node.component.as_str(), children_rule) {
731                ("Box", _) => NodeKind::Container,
732                (_, "none") => NodeKind::Leaf,
733                ("Button", "one") => {
734                    if has_children {
735                        NodeKind::Container
736                    } else {
737                        NodeKind::Leaf
738                    }
739                }
740                _ => NodeKind::Container,
741            }
742        };
743        let mut n = self.blank(&node.id, &component, parent, layer, kind, scope);
744        n.hidden |= force_hidden;
745        if let Some(e) = ext {
746            n.catalog_id = Some(e);
747            n.extension_kind = Some(node.component.clone());
748        }
749        // The author's `style` (top level) wins over a `style` PROP; both may
750        // carry bound values after expansion (resolved here).
751        let mut base: Props = props.get("style").and_then(Value::as_object).cloned().unwrap_or_default();
752        if let Some(s) = &node.style {
753            for (k, v) in self.resolve_map(s, scope) {
754                base.insert(k, v);
755            }
756        }
757        n.base_style = base;
758        n.lines = props.get("lines").and_then(Value::as_u64).map(|l| l as u32);
759        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") && str_prop(&props, "href").is_some_and(|href| !href.is_empty()))) && !bool_prop(&props, "disabled") && !bool_prop(&props, "loading");
760        n.on = node.on.clone();
761        n.source = self.sources.get(&node.id).cloned();
762        n.accessibility = node.accessibility.as_ref().and_then(|a| self.resolve(a, scope));
763        n.live = str_prop(&props, "live").filter(|l| *l != "off").map(str::to_string);
764        n.form_disabled = form_disabled;
765        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()));
766        if let Some(r) = &node.recipe {
767            n.part_query = Some(RecipeQuery::new(r.macro_.clone(), r.part.clone(), self.resolve_map(&r.props, scope), Vec::new()));
768            n.authored_keys = r.authored.clone();
769        }
770        if node.component == "Form" {
771            n.form = Some(node.id.clone());
772        }
773        n.props = props;
774        let index = self.push(n);
775        // VAPP-162: a held `hold` Button carries `pressed` (the core's state).
776        if node.component == "Button" && self.ctx.local.holds.get(&node.id).is_some_and(crate::hold::HoldTracker::active) {
777            self.add_state_own(index, "pressed");
778        }
779        if let Some(errors) = self.ctx.local.errors.get(&node.id) {
780            if !errors.messages.is_empty() {
781                let _ = errors;
782                self.add_state_own(index, "invalid");
783            }
784        }
785
786        // The component-specific structure; everything else: slots first
787        // (pre-order like the reference `preorder`), then children.
788        let expand = self.ctx.expand_controls;
789        // Round 2 §7: explicit sizes instead of browser defaults.
790        match node.component.as_str() {
791            "Image" | "Video" => self.media_ratio(index),
792            "ZoomableImage" => self.zoomable_box(index),
793            "Motion" => self.motion_box(index),
794            // VAPP-162: a vertical `bar` (THE navigation rail) is a column of
795            // fixed items: as wide as its root recipe (72), `items × tabBar`
796            // tall, never stretched across its parent (full height is the
797            // author's style). Painters stack the items top to bottom.
798            "Segmented" if crate::segmented::is_rail(&self.nodes[index as usize].props) => {
799                let tall = crate::segmented::rail_height(&self.nodes[index as usize].props, self.ctx.tab_bar);
800                self.style_default(index, &[("alignSelf", json!("flex-start")), ("flexShrink", json!(0)), ("height", json!(tall))])
801            }
802            // VAPP-164: a horizontal `bar` is `width: 100%` (base-css.ts), so
803            // it fills its parent even in an `align: start` column, with or
804            // without icons.
805            "Segmented" if str_prop(&self.nodes[index as usize].props, "variant") == Some("bar") => {
806                self.style_default(index, &[("width", json!("100%"))])
807            }
808            // A Segmented is content-sized unless `fill` (a `bar` always fills).
809            "Segmented" if !bool_prop(&self.nodes[index as usize].props, "fill") && str_prop(&self.nodes[index as usize].props, "variant") != Some("bar") => {
810                self.style_default(index, &[("alignSelf", json!("flex-start"))])
811            }
812            // VAPP-162: a Button's one custom child sits centred in its box
813            // (the web's inline-flex button; CallSurface's round 56 px
814            // buttons hold a 24 px Icon).
815            "Button" if self.nodes[index as usize].kind == NodeKind::Container => {
816                self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("center")), ("justifyContent", json!("center"))])
817            }
818            // NEW-77: the one child sits centred in the wave box at its own size.
819            "VoiceWave" => self.style_default(index, &[("display", json!("flex")), ("alignItems", json!("center")), ("justifyContent", json!("center"))]),
820            // TreeGuides: depth × `treeGuideColumn` wide, stretched to its row.
821            "TreeGuides" => {
822                let depth = self.nodes[index as usize].props.get("depth").and_then(Value::as_f64).unwrap_or(0.0).max(0.0);
823                self.style_default(index, &[("alignSelf", json!("stretch")), ("width", json!(depth * crate::layout::TREE_GUIDE_COLUMN)), ("flexShrink", json!(0))]);
824            }
825            _ => {}
826        }
827        match node.component.as_str() {
828            "Tabs" => self.tabs(index, node, scope),
829            "Accordion" => self.accordion(index, node, scope),
830            "Carousel" => self.carousel(index, node, scope),
831            "List" => self.list(index, node, scope),
832            "Resizable" => self.resizable(index, node, scope),
833            "Table" => self.table(index, node, scope),
834            "Toast" => self.toast(index, node),
835            c if Self::is_overlay(c) => self.overlay(index, node, scope),
836            "Form" => self.form(index, node, scope),
837            "Input" | "Textarea" if expand => self.field(index),
838            "Select" if expand => self.select(index),
839            "DatePicker" | "DateRangePicker" if expand => self.date_picker(index),
840            "TimePicker" if expand => self.time_picker(index),
841            "NumberField" if expand => self.number_field(index),
842            "ChipInput" if expand => self.chip_input(index),
843            "FileUpload" if expand => self.file_upload(index),
844            "CodeBlock" if expand => self.code_block(index),
845            "Checkbox" | "Switch" if expand => self.check(index),
846            "Radio" if expand => self.radio(index),
847            "ChoiceTiles" if expand => self.choice_tiles(index),
848            "Slider" if expand => self.slider(index),
849            "SearchList" if expand => self.search_list(index),
850            "Skeleton" => self.skeleton_rows(index),
851            "Chart" => self.chart(index),
852            _ => {
853                if let Some(slots) = &node.slots {
854                    for slot in slots.values() {
855                        self.add(slot, Some(index), layer, scope, false);
856                    }
857                }
858                self.children_of(index, node, scope, false);
859            }
860        }
861        Some(index)
862    }
863
864    /// A state on a node's OWN recipe query (an invalid field's root).
865    fn add_state_own(&mut self, index: u32, state: &str) {
866        if let Some(q) = &mut self.nodes[index as usize].own_query {
867            if !q.states.iter().any(|s| s == state) {
868                q.states.push(state.to_string());
869            }
870        }
871    }
872
873    /// Local UI state that overrides an UNBOUND prop for this build (the
874    /// host's latest field value, unbound chips/files/sort/selection).
875    fn local_overrides(&self, id: &str, component: &str, props: &mut Props) {
876        let local = &self.ctx.local;
877        if let Some(v) = local.field_values.get(id) {
878            match component {
879                "DateRangePicker" => {
880                    for k in ["start", "end"] {
881                        if let Some(x) = v.get(k) {
882                            props.insert(k.into(), x.clone());
883                        }
884                    }
885                }
886                _ => {
887                    let key = match component {
888                        "Checkbox" | "Switch" => "checked",
889                        "ChipInput" => "values",
890                        "FileUpload" => "files",
891                        _ => "value",
892                    };
893                    props.insert(key.into(), v.clone());
894                }
895            }
896        }
897        // VAPP-162: a held `hold` Button paints its `holdingLabel`.
898        if component == "Button" && local.holds.get(id).is_some_and(crate::hold::HoldTracker::active) {
899            if let Some(label) = props.get("holdingLabel").and_then(Value::as_str).filter(|l| !l.is_empty()).map(str::to_string) {
900                props.insert("label".into(), Value::String(label));
901            }
902        }
903        if component == "Toggle" {
904            if let Some(pressed) = local.pressed_toggles.get(id) {
905                props.insert("pressed".into(), Value::Bool(*pressed));
906            }
907        }
908        if component == "ChipInput" {
909            if let Some(chips) = local.chips.get(id) {
910                props.insert("values".into(), Value::Array(chips.iter().cloned().map(Value::String).collect()));
911            }
912        }
913        if component == "FileUpload" {
914            if let Some(files) = local.files.get(id) {
915                props.insert("files".into(), Value::Array(files.clone()));
916            }
917        }
918        if component == "Table" {
919            if let Some((key, dir)) = local.sort.get(id) {
920                props.insert("sort".into(), json!({"key": key, "direction": dir}));
921            }
922            if let Some(sel) = local.selected.get(id) {
923                props.insert("selected".into(), Value::Array(sel.iter().cloned().map(Value::String).collect()));
924            }
925        }
926    }
927
928    /// The id suffixes of a template's items (the reference's
929    /// `templateItems`): `.<index>` without a `key`; with one, `.<value>`
930    /// (objects as JSON; `~`/`.` escaped as `~0`/`~1`), and `.#<index>` when the value is missing, null,
931    /// empty or a DUPLICATE of an earlier item's (more `#` until unique), so
932    /// instance ids never collide. Once per array per build.
933    fn item_suffixes(&mut self, t: &crate::types::Template, base: &str) -> std::sync::Arc<Vec<String>> {
934        let cache_key = format!("{base}\u{0}{}", t.key.as_deref().unwrap_or(""));
935        if let Some(s) = self.suffixes.get(&cache_key) {
936            return s.clone();
937        }
938        let version = self.ctx.data_version;
939        if let Some((v, keys)) = self.ctx.local.key_cache.get(&format!("s\u{0}{cache_key}")) {
940            if *v == version {
941                let keys = keys.clone();
942                self.suffixes.insert(cache_key, keys.clone());
943                return keys;
944            }
945        }
946        let items = crate::data::get_pointer(self.ctx.data, base).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
947        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();
948        let out = std::sync::Arc::new(out);
949        self.ctx.local.key_cache.insert(format!("s\u{0}{cache_key}"), (version, out.clone()));
950        self.suffixes.insert(cache_key, out.clone());
951        out
952    }
953
954    /// The instance suffix `node` already wears (`.ops` for a node of the
955    /// `ops` item): a template inside it ACCUMULATES it (round 2,
956    /// `issue.title.ops.1`), so nested items never collide.
957    pub(crate) fn enclosing_suffix(&self, node: &UiNode) -> String {
958        match self.sources.get(&node.id) {
959            Some(src) if node.id.len() > src.id.len() && node.id.starts_with(src.id.as_str()) => node.id[src.id.len()..].to_string(),
960            _ => String::new(),
961        }
962    }
963
964    /// The node's children (static or from its template) under `parent`.
965    pub(crate) fn children_of(&mut self, parent: u32, node: &UiNode, scope: &str, hidden: bool) -> Vec<u32> {
966        let mut out = Vec::new();
967        let layer = self.nodes[parent as usize].layer;
968        for child in &node.children {
969            out.extend(self.add(child, Some(parent), layer, scope, hidden));
970        }
971        if let Some(t) = &node.template {
972            let base = crate::data::absolute_path(&t.path, scope);
973            let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len).unwrap_or(0);
974            if let Some(tpl) = (self.ctx.template)(&t.component) {
975                let suffixes = self.item_suffixes(t, &base);
976                let enclosing = self.enclosing_suffix(node);
977                for i in 0..count {
978                    if !self.take_template_item(&t.component, &tpl) {
979                        break;
980                    }
981                    let item_scope = format!("{base}/{i}");
982                    let suffix = format!("{enclosing}{}", suffixes[i]);
983                    let item = self.instance(&tpl, &suffix, Some(&t.component));
984                    out.extend(self.add(&item, Some(parent), layer, &item_scope, hidden));
985                }
986            }
987        }
988        out
989    }
990
991    /// Count one template item against `maxTemplateItems` and its template
992    /// subtree's nodes (the template node + every descendant through
993    /// children and slots; nested template items are charged when THEY are
994    /// instantiated) against `maxComponents`, on top of the reduced tree's
995    /// own nodes (the build starts there). The first item that does not
996    /// fit either is refused with ONE issue per build, and so is every
997    /// later item (the rest is not rendered).
998    fn take_template_item(&mut self, template: &str, tpl: &UiNode) -> bool {
999        if self.template_refused {
1000            return false;
1001        }
1002        let nodes = subtree_nodes(tpl);
1003        let message = if self.template_items >= crate::limits::MAX_TEMPLATE_ITEMS {
1004            crate::limits::template_items_issue()
1005        } else if self.template_nodes + nodes > crate::limits::MAX_COMPONENTS {
1006            crate::limits::components_issue()
1007        } else {
1008            self.template_items += 1;
1009            self.template_nodes += nodes;
1010            return true;
1011        };
1012        self.template_refused = true;
1013        self.issues.push(crate::types::ReduceIssue { id: template.to_string(), message });
1014        false
1015    }
1016
1017    /// The keys of a node's children without building them (the windowing
1018    /// keys: static ids, then template items by key or index). Static lists
1019    /// are computed once per reduce.
1020    pub(crate) fn child_keys(&mut self, node: &UiNode, scope: &str) -> std::sync::Arc<Vec<String>> {
1021        if node.template.is_none() {
1022            if let Some(k) = self.ctx.local.static_keys.get(&node.id) {
1023                if k.len() == node.children.len() {
1024                    return k.clone();
1025                }
1026            }
1027            let keys = std::sync::Arc::new(node.children.iter().map(|c| c.id.clone()).collect::<Vec<_>>());
1028            self.ctx.local.static_keys.insert(node.id.clone(), keys.clone());
1029            return keys;
1030        }
1031        let enclosing = self.enclosing_suffix(node);
1032        let cache_key = format!("c\u{0}{}\u{0}{scope}", node.id);
1033        if let Some((v, keys)) = self.ctx.local.key_cache.get(&cache_key) {
1034            if *v == self.ctx.data_version && keys.len() >= node.children.len() {
1035                return keys.clone();
1036            }
1037        }
1038        let mut keys: Vec<String> = node.children.iter().map(|c| c.id.clone()).collect();
1039        if let Some(t) = &node.template {
1040            let base = crate::data::absolute_path(&t.path, scope);
1041            for suffix in self.item_suffixes(t, &base).iter() {
1042                keys.push(format!("{}{enclosing}{suffix}", t.component));
1043            }
1044        }
1045        let keys = std::sync::Arc::new(keys);
1046        self.ctx.local.key_cache.insert(cache_key, (self.ctx.data_version, keys.clone()));
1047        keys
1048    }
1049
1050    /// Child `i` (static children first, then template items).
1051    pub(crate) fn one_child(&mut self, parent: u32, node: &UiNode, i: usize, scope: &str, hidden: bool) -> Option<u32> {
1052        let layer = self.nodes[parent as usize].layer;
1053        if let Some(child) = node.children.get(i) {
1054            return self.add(child, Some(parent), layer, scope, hidden);
1055        }
1056        let t = node.template.as_ref()?;
1057        let base = crate::data::absolute_path(&t.path, scope);
1058        let count = crate::data::get_pointer(self.ctx.data, &base).and_then(Value::as_array).map(Vec::len)?;
1059        let idx = i.checked_sub(node.children.len())?;
1060        if idx >= count {
1061            return None;
1062        }
1063        let tpl = (self.ctx.template)(&t.component)?;
1064        if !self.take_template_item(&t.component, &tpl) {
1065            return None;
1066        }
1067        let item_scope = format!("{base}/{idx}");
1068        let suffix = format!("{}{}", self.enclosing_suffix(node), self.item_suffixes(t, &base).get(idx).cloned().unwrap_or_else(|| format!(".{idx}")));
1069        let item = self.instance(&tpl, &suffix, Some(&t.component));
1070        self.add(&item, Some(parent), layer, &item_scope, hidden)
1071    }
1072
1073    /// A responsive native prop at the active breakpoint (`Drawer.side`).
1074    pub(crate) fn responsive_prop(&mut self, props: &Props, key: &str) -> Option<Value> {
1075        let v = props.get(key)?;
1076        if crate::macros::is_responsive_value(v) {
1077            self.responsive = true;
1078            return crate::macros::responsive_at(v, self.ctx.breakpoint.as_deref()).cloned();
1079        }
1080        Some(v.clone())
1081    }
1082
1083    /// Is the overlay `id` open (local state over the prop)?
1084    pub(crate) fn is_open(&self, id: &str, props: &Props, default: bool) -> bool {
1085        self.ctx.local.open.get(id).copied().unwrap_or_else(|| props.get("open").and_then(Value::as_bool).unwrap_or(default))
1086    }
1087
1088    /// Start a new layer: its root container (a synthetic part of `owner`).
1089    pub(crate) fn layer_root(&mut self, owner: &LNode, part: &str, style: Value) -> (u32, u32) {
1090        let layer = self.next_layer;
1091        self.next_layer += 1;
1092        let mut root = self.blank(&format!("{}.{part}", owner.id), "Box", None, layer, NodeKind::Container, &owner.scope);
1093        root.part = Some(part.to_string());
1094        root.owner = Some(owner.id.clone());
1095        root.owner_component = Some(owner.component.clone());
1096        root.form = owner.form.clone();
1097        root.base_style = obj(style);
1098        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()]));
1099        let index = self.push(root);
1100        (layer, index)
1101    }
1102
1103    #[allow(clippy::too_many_arguments)]
1104    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>) {
1105        let class = if matches!(placement, LayerPlacement::Toast { .. }) { LayerClass::Toast } else { LayerClass::Overlay };
1106        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 });
1107    }
1108}
1109
1110/// `/$row/<table>/<i>[/…]` → `/$row/<table>/<i>` (a literal Table row's
1111/// scope root).
1112pub fn row_scope_root(scope: &str) -> Option<&str> {
1113    let rest = scope.strip_prefix("/$row/")?;
1114    let mut end = "/$row/".len();
1115    for (k, part) in rest.splitn(3, '/').enumerate().take(2) {
1116        end += part.len() + usize::from(k == 0);
1117    }
1118    Some(&scope[..end])
1119}
1120
1121/// The builtin glyph of a part (`core.catalog.json` `builtinIcons`).
1122pub fn builtin_icon(slot: &str) -> Option<&'static str> {
1123    let g = crate::generated::catalog::BUILTIN_ICON_SLOTS.iter().position(|s| *s == slot)?;
1124    crate::generated::catalog::BUILTIN_ICON_NAMES.get(g).copied()
1125}
1126
1127/// Build the layout tree for a normalized root. `Unknown` nodes become
1128/// leaves the painter renders as the placeholder.
1129pub fn build(root: &UiNode, ctx: &mut BuildContext) -> Built {
1130    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() };
1131    if b.add(root, None, 0, "", false).is_none() {
1132        // An invisible root: an empty surface (one hidden Box).
1133        let mut n = b.blank(&root.id, "Box", None, 0, NodeKind::Container, "");
1134        n.hidden = true;
1135        b.push(n);
1136    }
1137    b.flush_toasts();
1138    // Layers in paint order: a parent layer before the layers it opened
1139    // (layer numbers are handed out when a layer's root is created).
1140    b.layers.sort_by_key(|l| (l.class == LayerClass::Toast, l.layer));
1141    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 }
1142}
1143
1144/// Rebuild ONE subtree (a re-windowed List/Table) under an existing parent:
1145/// node 0 of the result is a stub of `parent` (the caller keeps the real
1146/// one), the rest is the subtree in pre-order. `seed` = what the full build
1147/// knew above it (the enclosing Forms' disabled/busy, literal Table rows),
1148/// so the subtree comes out exactly as a full rebuild would build it.
1149pub fn build_subtree(node: &UiNode, parent: &LNode, scope: &str, seed: BuildSeed, ctx: &mut BuildContext) -> Built {
1150    let mut stub = parent.clone();
1151    stub.index = 0;
1152    stub.parent = None;
1153    stub.children.clear();
1154    let layer = stub.layer;
1155    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() };
1156    b.add(node, Some(0), layer, scope, false);
1157    b.flush_toasts();
1158    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 }
1159}
1160
1161/// EXP-1604: a Motion's composition size in px: the dialect's `size`, else
1162/// the inline Lottie's `w` × `h`; `None` for a `src` animation.
1163pub fn motion_size(props: &Props) -> Option<(f64, f64)> {
1164    let pos = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1165    let size = props.get("size").and_then(Value::as_array);
1166    let from_size = size.and_then(|a| Some((pos(a.first())?, pos(a.get(1))?)));
1167    from_size.or_else(|| {
1168        let l = props.get("lottie")?;
1169        Some((pos(l.get("w"))?, pos(l.get("h"))?))
1170    })
1171}
1172
1173/// EXP-1586: a ZoomableImage's width / height without a style `height`:
1174/// naturalWidth / naturalHeight (a TALL picture, w/h under `tallAspect`,
1175/// capped at a square so a full-page capture opens as a window onto its
1176/// top), else `mediaAspectRatio` (16:9).
1177pub fn zoomable_aspect_ratio(props: &Props) -> f64 {
1178    let num = |k: &str| props.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
1179    match (num("naturalWidth"), num("naturalHeight")) {
1180        (Some(w), Some(h)) if crate::zoom::zoom_is_tall(crate::zoom::ZoomSize { width: w, height: h }) => 1.0,
1181        (Some(w), Some(h)) => w / h,
1182        _ => crate::layout::MEDIA_ASPECT_RATIO,
1183    }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    #[test]
1191    fn builtin_glyphs_resolve() {
1192        assert_eq!(builtin_icon("CodeBlock.copy"), Some("ui-copy"));
1193        assert_eq!(builtin_icon("Table.sortIcon.asc"), Some("ui-chevron-up"));
1194        assert_eq!(builtin_icon("Nope.x"), None);
1195    }
1196
1197    #[test]
1198    fn a_zoomable_box_follows_its_picture() {
1199        let p = |v: Value| v.as_object().cloned().unwrap_or_default();
1200        assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 1440, "naturalHeight": 900}))) - 1.6).abs() < 1e-9);
1201        // A tall picture opens as a square window onto its top.
1202        assert_eq!(zoomable_aspect_ratio(&p(json!({"naturalWidth": 1000, "naturalHeight": 8000}))), 1.0);
1203        assert!((zoomable_aspect_ratio(&p(json!({"naturalWidth": 600, "naturalHeight": 900}))) - 600.0 / 900.0).abs() < 1e-9);
1204        assert_eq!(zoomable_aspect_ratio(&p(json!({}))), crate::layout::MEDIA_ASPECT_RATIO);
1205    }
1206}