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