Skip to main content

gpui_rhai/
node.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4use std::rc::{Rc, Weak};
5
6use rhai::{
7    Array, CustomType, Dynamic, EvalAltResult, FLOAT, FnPtr, INT, ImmutableString, Map,
8    NativeCallContext, Position, TypeBuilder,
9};
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::{
13    AssetId, ComponentInstancePath, HostCallback, MotionSource, OpaqueHandle, OverlayId,
14    OverlayKind, OverlayPlacement, PrimitiveNode, ScriptCallback, ScriptGeneration, Style,
15    UiEventBinding, UiEventHandler, UiValue,
16};
17
18#[derive(Clone, Debug, PartialEq)]
19pub enum ImageSourceSpec {
20    Handle(OpaqueHandle),
21    Asset(AssetId),
22}
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct OverlayNodeSpec {
26    pub id: OverlayId,
27    /// The component instance that declared the overlay, set when its render
28    /// binds the node. With `id` it is the overlay's identity, so two
29    /// instances of one component do not share an overlay.
30    pub owner: Option<crate::ComponentInstancePath>,
31    pub parent: Option<OverlayId>,
32    pub kind: OverlayKind,
33    pub placement: OverlayPlacement,
34    /// Cross-axis alignment against the trigger; logical `Start`/`End`.
35    pub align: crate::OverlayAlign,
36    pub anchor: Option<crate::OverlayBounds>,
37    pub open: bool,
38    pub gap: f64,
39    pub modal: bool,
40    pub dismiss: OverlayDismissPolicy,
41    pub tooltip_delays: Option<TooltipDelays>,
42    pub initial_focus: OverlayInitialFocus,
43    pub activate_on_trigger: bool,
44    pub width_policy: OverlayWidthPolicy,
45}
46
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
48pub enum OverlayWidthPolicy {
49    #[default]
50    Content,
51    MatchTrigger,
52}
53
54/// Where focus lands on each closed -> open presentation cycle of a modal
55/// or menu overlay. This is an overlay-lifecycle contract, deliberately not
56/// tied to entity mount: overlays keep their semantic subtree (and thus
57/// primitive identity) across close/reopen, so mount-time mechanisms like
58/// input `autofocus` fire only on the first open.
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
60pub enum OverlayInitialFocus {
61    /// Focus the overlay panel itself (historical behavior).
62    #[default]
63    Panel,
64    /// Focus the first focusable element inside the panel once the content
65    /// has entered the focus tree.
66    ///
67    /// Note: any node with interaction handlers (click/hover/key) gets an
68    /// interaction wrapper, and wrappers are tab stops by default. A content
69    /// container that listens for keys (arrow navigation, say) will itself
70    /// be "first" unless it opts out with `tab_stop(false)`.
71    First,
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum LayerPlacement {
76    TopLeft,
77    TopRight,
78    BottomLeft,
79    BottomRight,
80    Center,
81    Fill,
82}
83
84#[derive(Clone, Debug, PartialEq)]
85pub struct LayerNodeSpec {
86    pub id: OverlayId,
87    /// The declaring component instance, as for overlays.
88    pub owner: Option<crate::ComponentInstancePath>,
89    pub placement: LayerPlacement,
90    pub inset: f64,
91    pub priority: usize,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub struct TooltipDelays {
96    pub show_ms: u64,
97    pub hide_ms: u64,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct OverlayDismissPolicy {
102    pub escape: bool,
103    pub outside: bool,
104}
105
106/// Stable identity for a node among its siblings.
107#[derive(Clone, Debug, Eq, Hash, PartialEq)]
108pub struct NodeKey(ImmutableString);
109
110impl NodeKey {
111    #[must_use]
112    pub fn new(value: impl Into<ImmutableString>) -> Self {
113        Self(value.into())
114    }
115
116    #[must_use]
117    pub fn as_str(&self) -> &str {
118        self.0.as_str()
119    }
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct SourceLocation {
124    pub module: String,
125    pub line: u32,
126    pub column: u32,
127}
128
129#[derive(Clone, Debug, PartialEq)]
130pub struct Span {
131    text: ImmutableString,
132    key: Option<ImmutableString>,
133    color: Option<crate::ColorValue>,
134    background: Option<crate::ColorValue>,
135    typography: Option<ImmutableString>,
136    bold: bool,
137    italic: bool,
138    motions: Vec<MotionSource>,
139}
140
141impl Span {
142    #[must_use]
143    pub fn new(text: impl Into<ImmutableString>) -> Self {
144        Self {
145            text: text.into(),
146            key: None,
147            color: None,
148            background: None,
149            typography: None,
150            bold: false,
151            italic: false,
152            motions: Vec::new(),
153        }
154    }
155
156    /// A background behind the span's text (inline code).
157    #[must_use]
158    pub fn background(mut self, color: crate::ColorValue) -> Self {
159        self.background = Some(color);
160        self
161    }
162
163    /// The family and weight of a typography role; the size and line height
164    /// stay the paragraph's, so the span sits on its line.
165    #[must_use]
166    pub fn typography(mut self, role: impl Into<ImmutableString>) -> Self {
167        self.typography = Some(role.into());
168        self
169    }
170
171    #[must_use]
172    pub fn color(mut self, color: crate::ColorValue) -> Self {
173        self.color = Some(color);
174        self
175    }
176
177    #[must_use]
178    pub fn with_key(mut self, key: impl Into<ImmutableString>) -> Self {
179        self.key = Some(key.into());
180        self
181    }
182
183    #[must_use]
184    pub fn motion(mut self, source: MotionSource) -> Self {
185        self.motions
186            .retain(|existing| existing.property() != source.property());
187        self.motions.push(source);
188        self
189    }
190
191    #[must_use]
192    pub const fn bold(mut self) -> Self {
193        self.bold = true;
194        self
195    }
196
197    #[must_use]
198    pub const fn italic(mut self) -> Self {
199        self.italic = true;
200        self
201    }
202
203    #[must_use]
204    pub fn text(&self) -> &str {
205        self.text.as_str()
206    }
207
208    #[must_use]
209    pub fn key(&self) -> Option<&str> {
210        self.key.as_deref()
211    }
212
213    #[must_use]
214    pub fn motions(&self) -> &[MotionSource] {
215        &self.motions
216    }
217
218    #[must_use]
219    pub const fn color_value(&self) -> Option<&crate::ColorValue> {
220        self.color.as_ref()
221    }
222
223    #[must_use]
224    pub const fn background_value(&self) -> Option<&crate::ColorValue> {
225        self.background.as_ref()
226    }
227
228    #[must_use]
229    pub fn typography_role(&self) -> Option<&str> {
230        self.typography.as_deref()
231    }
232
233    #[must_use]
234    pub const fn is_bold(&self) -> bool {
235        self.bold
236    }
237
238    #[must_use]
239    pub const fn is_italic(&self) -> bool {
240        self.italic
241    }
242}
243
244impl CustomType for Span {
245    fn build(mut builder: TypeBuilder<Self>) {
246        builder
247            .with_name("Span")
248            .with_fn("with_key", |span: &mut Self, key: ImmutableString| {
249                span.clone().with_key(key)
250            })
251            .with_fn("color", |span: &mut Self, color: crate::ColorValue| {
252                span.clone().color(color)
253            })
254            .with_fn("background", |span: &mut Self, color: crate::ColorValue| {
255                span.clone().background(color)
256            })
257            .with_fn("typography", |span: &mut Self, role: ImmutableString| {
258                span.clone().typography(role)
259            })
260            .with_fn("motion", |span: &mut Self, source: MotionSource| {
261                span.clone().motion(source)
262            })
263            .with_fn("bold", |span: &mut Self| span.clone().bold())
264            .with_fn("italic", |span: &mut Self| span.clone().italic());
265    }
266}
267
268#[derive(Clone, Debug, PartialEq)]
269pub enum UiNodeKind {
270    Text {
271        text: ImmutableString,
272    },
273    RichText {
274        text: ImmutableString,
275        spans: Vec<Span>,
276    },
277    Canvas {
278        scene: crate::CanvasScene,
279    },
280    Svg {
281        source: crate::InlineSvg,
282    },
283    Box {
284        children: Vec<UiNode>,
285    },
286    Fragment {
287        children: Vec<UiNode>,
288    },
289    Custom {
290        primitive: PrimitiveNode,
291    },
292    Image {
293        source: ImageSourceSpec,
294    },
295    DirectionalImage {
296        left_to_right: ImageSourceSpec,
297        right_to_left: ImageSourceSpec,
298    },
299    Overlay {
300        trigger: Box<UiNode>,
301        content: Box<UiNode>,
302        spec: OverlayNodeSpec,
303    },
304    Layer {
305        content: Box<UiNode>,
306        spec: LayerNodeSpec,
307    },
308    VirtualCollection {
309        spec: crate::VirtualCollectionNodeSpec,
310    },
311    ErrorBoundary {
312        child: Box<UiNode>,
313        fallback: Box<UiNode>,
314    },
315}
316
317/// Stable discriminant used by retained reconciliation without exposing GPUI
318/// element types or borrowing a complete [`UiNodeKind`] payload.
319#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
320pub enum UiNodeKindTag {
321    Text,
322    Canvas,
323    Svg,
324    Box,
325    Fragment,
326    Custom,
327    Image,
328    DirectionalImage,
329    Overlay,
330    Layer,
331    VirtualCollection,
332    ErrorBoundary,
333}
334
335/// A stable declarative UI node. Script-produced nodes contain no GPUI values
336/// or lifetimes; trusted Rust Hosts may attach opaque foreground callbacks.
337///
338/// A node is a shared, copy-on-write handle: Rhai passes nodes by value
339/// (variables, arrays, arguments), so a clone must not copy the subtree.
340#[derive(Clone)]
341pub struct UiNode(Rc<UiNodeData>);
342
343impl std::fmt::Debug for UiNode {
344    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        self.0.fmt(formatter)
346    }
347}
348
349impl PartialEq for UiNode {
350    fn eq(&self, other: &Self) -> bool {
351        Rc::ptr_eq(&self.0, &other.0) || self.0 == other.0
352    }
353}
354
355impl std::ops::Deref for UiNode {
356    type Target = UiNodeData;
357
358    fn deref(&self) -> &UiNodeData {
359        &self.0
360    }
361}
362
363impl std::ops::DerefMut for UiNode {
364    fn deref_mut(&mut self) -> &mut UiNodeData {
365        Rc::make_mut(&mut self.0)
366    }
367}
368
369impl From<UiNodeData> for UiNode {
370    fn from(data: UiNodeData) -> Self {
371        Self(Rc::new(data))
372    }
373}
374
375/// Style variants chosen by the value of a native string signal, such as the
376/// `idle`/`hover`/`drag`/`focus`/`disabled` state a resize primitive writes.
377/// Pointer moves change the signal, never the Rhai tree.
378#[derive(Clone, Debug, PartialEq)]
379pub struct SignalStyle {
380    pub signal: crate::NativeSignal,
381    pub states: BTreeMap<String, Style>,
382}
383
384/// The contents of a [`UiNode`]; reached only through the node's methods.
385#[doc(hidden)]
386#[derive(Clone, Debug, PartialEq)]
387pub struct UiNodeData {
388    kind: UiNodeKind,
389    key: Option<NodeKey>,
390    style: Rc<Style>,
391    part_styles: BTreeMap<String, Style>,
392    source: Option<SourceLocation>,
393    component_root: Option<ComponentInstancePath>,
394    attributes: BTreeMap<String, UiValue>,
395    handlers: BTreeMap<String, Vec<UiEventBinding>>,
396    handler_payloads: BTreeMap<String, UiValue>,
397    motions: Vec<MotionSource>,
398    exit_motions: Vec<MotionSource>,
399    progress_motions: Vec<crate::MotionProgressBinding>,
400    timelines: Vec<crate::MotionTimeline>,
401    signal_bindings: BTreeMap<crate::SignalProperty, crate::NativeSignal>,
402    signal_style: Option<Rc<SignalStyle>>,
403    table_layout: Option<Rc<crate::table_layout::TableLayout>>,
404    table_column: Option<usize>,
405    element_ref: Option<crate::ElementRef>,
406    presentation: Vec<NodePresentationMutation>,
407    component_snapshot: Option<ComponentOwnedSnapshotRef>,
408}
409
410fn default_node_style() -> Rc<Style> {
411    thread_local! {
412        static STYLE: Rc<Style> = Rc::new(Style::new());
413    }
414    STYLE.with(Rc::clone)
415}
416
417#[derive(Clone, Default)]
418pub(crate) struct ComponentOwnedSnapshot(Rc<RefCell<Option<Rc<UiNode>>>>);
419
420#[derive(Clone, Default)]
421pub(crate) struct ComponentOwnedSnapshotRef(Weak<RefCell<Option<Rc<UiNode>>>>);
422
423impl ComponentOwnedSnapshot {
424    pub(crate) fn reference(&self) -> ComponentOwnedSnapshotRef {
425        ComponentOwnedSnapshotRef(Rc::downgrade(&self.0))
426    }
427
428    pub(crate) fn current(&self) -> Option<Rc<UiNode>> {
429        self.0.borrow().clone()
430    }
431
432    pub(crate) fn restore(&self, snapshot: Option<Rc<UiNode>>) {
433        *self.0.borrow_mut() = snapshot;
434    }
435
436    pub(crate) fn update_if_active(&self, node: &UiNode) {
437        let mut snapshot = self.0.borrow_mut();
438        if snapshot.is_some() {
439            *snapshot = Some(Rc::new(node.clone()));
440        }
441    }
442
443    pub(crate) fn replace_if_active(&self, node: Rc<UiNode>) {
444        let mut snapshot = self.0.borrow_mut();
445        if snapshot.is_some() {
446            *snapshot = Some(node);
447        }
448    }
449}
450
451impl fmt::Debug for ComponentOwnedSnapshot {
452    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
453        formatter
454            .debug_struct("ComponentOwnedSnapshot")
455            .field("active", &self.0.borrow().is_some())
456            .finish()
457    }
458}
459
460impl ComponentOwnedSnapshotRef {
461    fn current(&self) -> Option<Rc<UiNode>> {
462        self.0.upgrade()?.borrow().clone()
463    }
464
465    fn activate(&self, node: &UiNode) {
466        let Some(snapshot) = self.0.upgrade() else {
467            return;
468        };
469        let mut snapshot = snapshot.borrow_mut();
470        if snapshot.is_none() {
471            *snapshot = Some(Rc::new(node.clone()));
472        }
473    }
474}
475
476impl fmt::Debug for ComponentOwnedSnapshotRef {
477    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
478        formatter
479            .debug_struct("ComponentOwnedSnapshotRef")
480            .field("live", &(self.0.strong_count() > 0))
481            .finish()
482    }
483}
484
485impl PartialEq for ComponentOwnedSnapshotRef {
486    fn eq(&self, _: &Self) -> bool {
487        // Snapshot links are runtime ownership metadata. Effective node data
488        // and presentation mutations already participate in UiNode equality;
489        // comparing allocation identity would make equivalent full renders
490        // differ across transactions and engines.
491        true
492    }
493}
494
495// Caller-visible layout metadata follows the same snapshot/replay boundary as style.
496#[derive(Clone, Debug, PartialEq)]
497enum NodePresentationMutation {
498    Key(NodeKey),
499    Style(Rc<Style>),
500    PartStyles(Rc<BTreeMap<String, Style>>),
501    Signal(crate::SignalProperty, crate::NativeSignal),
502    SignalStyle(Rc<SignalStyle>),
503    TableTrack(Rc<crate::table_layout::TableLayout>),
504    TableColumn(usize),
505    ElementRef(crate::ElementRef),
506    Attribute(String, UiValue),
507    Handler(String, UiEventBinding),
508    HandlerPayload(String, UiValue),
509    Motion(MotionSource),
510    ExitMotion(MotionSource),
511    ProgressMotion(crate::MotionProgressBinding),
512    Timeline(Box<crate::MotionTimeline>),
513    /// A surrounding `motion_group`: its name and, once bound, the component
514    /// instance that declared it.
515    MotionGroup(String, Option<String>),
516}
517
518impl NodePresentationMutation {
519    fn apply(&self, node: &mut UiNode) {
520        match self {
521            Self::Key(key) => node.key = Some(key.clone()),
522            Self::Style(style) => Rc::make_mut(&mut node.style).merge_in_place(style),
523            Self::PartStyles(styles) => node.part_styles.extend(styles.as_ref().clone()),
524            Self::Signal(property, signal) => {
525                node.signal_bindings.insert(*property, signal.clone());
526            }
527            Self::SignalStyle(style) => node.signal_style = Some(Rc::clone(style)),
528            Self::TableTrack(layout) => node.table_layout = Some(Rc::clone(layout)),
529            Self::TableColumn(index) => node.table_column = Some(*index),
530            Self::ElementRef(reference) => node.element_ref = Some(reference.clone()),
531            Self::Attribute(name, value) => {
532                node.attributes.insert(name.clone(), value.clone());
533            }
534            Self::Handler(event, binding) => {
535                node.handlers
536                    .entry(event.clone())
537                    .or_default()
538                    .push(binding.clone());
539            }
540            Self::HandlerPayload(event, payload) => {
541                node.handler_payloads.insert(event.clone(), payload.clone());
542            }
543            Self::Motion(animation) => {
544                node.motions
545                    .retain(|existing| existing.property() != animation.property());
546                node.motions.push(animation.clone());
547            }
548            Self::ExitMotion(motion) => {
549                node.exit_motions
550                    .retain(|existing| existing.property() != motion.property());
551                node.exit_motions.push(motion.clone());
552            }
553            Self::ProgressMotion(binding) => {
554                node.progress_motions
555                    .retain(|existing| existing.property() != binding.property());
556                node.progress_motions.push(binding.clone());
557            }
558            Self::Timeline(timeline) => {
559                node.timelines
560                    .retain(|existing| existing.name != timeline.name);
561                node.timelines.push(timeline.as_ref().clone());
562            }
563            Self::MotionGroup(group, scope) => {
564                apply_motion_group_contents(node, group, scope.as_deref());
565            }
566        }
567    }
568
569    fn bind_generation(&mut self, generation: ScriptGeneration) {
570        match self {
571            Self::Handler(_, binding) => {
572                if let Some(callback) = binding.handler_mut().as_script_mut() {
573                    callback.bind_generation(generation);
574                }
575            }
576            Self::Timeline(timeline) => timeline.bind_generation(generation),
577            _ => {}
578        }
579    }
580
581    fn bind_component_scope(
582        &mut self,
583        component: &ComponentInstancePath,
584        incarnation: crate::ComponentIncarnation,
585        events: &BTreeMap<String, crate::EventSchema>,
586        native_context: Option<&crate::invocation::ScriptInvocationContext>,
587    ) {
588        match self {
589            Self::Handler(_, binding) => {
590                if let Some(callback) = binding.handler_mut().as_script_mut() {
591                    callback.bind_component_scope_if_unset(component, incarnation, events.clone());
592                    if let Some(context) = native_context {
593                        callback.bind_native_context_if_unset(context.clone());
594                    }
595                }
596            }
597            Self::Timeline(timeline) => {
598                timeline.bind_component_scope(component, incarnation, events, native_context);
599            }
600            // Replays after the declaring component's render keep its scope.
601            Self::MotionGroup(_, scope) => {
602                scope.get_or_insert_with(|| component.to_string());
603            }
604            _ => {}
605        }
606    }
607}
608
609#[derive(Clone, Debug)]
610pub(crate) struct ComponentSubtreeIndex {
611    addresses: BTreeMap<ComponentInstancePath, Vec<ComponentSubtreeStep>>,
612}
613
614#[derive(Clone, Debug)]
615enum ComponentSubtreeStep {
616    Child(usize),
617    CustomNode(String),
618    CustomNodes(String, usize),
619    OverlayTrigger,
620    OverlayContent,
621    LayerContent,
622    VirtualItem(usize),
623    ErrorChild,
624    ErrorFallback,
625}
626
627impl ComponentSubtreeIndex {
628    pub(crate) fn contains(&self, component: &ComponentInstancePath) -> bool {
629        self.addresses.contains_key(component)
630    }
631
632    pub(crate) fn new(root: &UiNode) -> Self {
633        let mut index = Self {
634            addresses: BTreeMap::new(),
635        };
636        index.visit(root, &mut Vec::new());
637        index
638    }
639
640    pub(crate) fn get<'a>(
641        &self,
642        root: &'a UiNode,
643        component: &ComponentInstancePath,
644    ) -> Option<&'a UiNode> {
645        let mut node = root;
646        for step in self.addresses.get(component)? {
647            node = match (step, &node.kind) {
648                (
649                    ComponentSubtreeStep::Child(index),
650                    UiNodeKind::Box { children } | UiNodeKind::Fragment { children },
651                ) => children.get(*index)?,
652                (ComponentSubtreeStep::CustomNode(name), UiNodeKind::Custom { primitive }) => {
653                    match primitive.props.get(name.as_str())? {
654                        crate::PrimitiveValue::Node(node) => node,
655                        _ => return None,
656                    }
657                }
658                (
659                    ComponentSubtreeStep::CustomNodes(name, index),
660                    UiNodeKind::Custom { primitive },
661                ) => match primitive.props.get(name.as_str())? {
662                    crate::PrimitiveValue::Nodes(nodes) => nodes.get(*index)?,
663                    _ => return None,
664                },
665                (ComponentSubtreeStep::OverlayTrigger, UiNodeKind::Overlay { trigger, .. }) => {
666                    trigger
667                }
668                (ComponentSubtreeStep::OverlayContent, UiNodeKind::Overlay { content, .. }) => {
669                    content
670                }
671                (ComponentSubtreeStep::LayerContent, UiNodeKind::Layer { content, .. }) => content,
672                (
673                    ComponentSubtreeStep::VirtualItem(index),
674                    UiNodeKind::VirtualCollection { spec },
675                ) => spec.realized.get(index)?,
676                (ComponentSubtreeStep::ErrorChild, UiNodeKind::ErrorBoundary { child, .. }) => {
677                    child
678                }
679                (
680                    ComponentSubtreeStep::ErrorFallback,
681                    UiNodeKind::ErrorBoundary { fallback, .. },
682                ) => fallback,
683                _ => return None,
684            };
685        }
686        Some(node)
687    }
688
689    fn visit(&mut self, node: &UiNode, address: &mut Vec<ComponentSubtreeStep>) {
690        if let Some(component) = node.component_root() {
691            self.addresses.insert(component.clone(), address.clone());
692        }
693        match &node.kind {
694            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
695                for (index, child) in children.iter().enumerate() {
696                    address.push(ComponentSubtreeStep::Child(index));
697                    self.visit(child, address);
698                    address.pop();
699                }
700            }
701            UiNodeKind::Custom { primitive } => {
702                for (name, value) in primitive.props.iter() {
703                    match value {
704                        crate::PrimitiveValue::Node(node) => {
705                            address.push(ComponentSubtreeStep::CustomNode(name.to_string()));
706                            self.visit(node.as_ref(), address);
707                            address.pop();
708                        }
709                        crate::PrimitiveValue::Nodes(nodes) => {
710                            for (index, node) in nodes.iter().enumerate() {
711                                address.push(ComponentSubtreeStep::CustomNodes(
712                                    name.to_string(),
713                                    index,
714                                ));
715                                self.visit(node, address);
716                                address.pop();
717                            }
718                        }
719                        _ => {}
720                    }
721                }
722            }
723            UiNodeKind::Overlay {
724                trigger, content, ..
725            } => {
726                address.push(ComponentSubtreeStep::OverlayTrigger);
727                self.visit(trigger, address);
728                address.pop();
729                address.push(ComponentSubtreeStep::OverlayContent);
730                self.visit(content, address);
731                address.pop();
732            }
733            UiNodeKind::Layer { content, .. } => {
734                address.push(ComponentSubtreeStep::LayerContent);
735                self.visit(content, address);
736                address.pop();
737            }
738            UiNodeKind::VirtualCollection { spec } => {
739                for (index, node) in &spec.realized {
740                    address.push(ComponentSubtreeStep::VirtualItem(*index));
741                    self.visit(node, address);
742                    address.pop();
743                }
744            }
745            UiNodeKind::ErrorBoundary { child, fallback } => {
746                address.push(ComponentSubtreeStep::ErrorChild);
747                self.visit(child, address);
748                address.pop();
749                address.push(ComponentSubtreeStep::ErrorFallback);
750                self.visit(fallback, address);
751                address.pop();
752            }
753            UiNodeKind::Text { .. }
754            | UiNodeKind::RichText { .. }
755            | UiNodeKind::Canvas { .. }
756            | UiNodeKind::Svg { .. }
757            | UiNodeKind::Image { .. }
758            | UiNodeKind::DirectionalImage { .. } => {}
759        }
760    }
761}
762
763impl UiNode {
764    #[must_use]
765    pub fn text(text: impl Into<ImmutableString>) -> Self {
766        UiNodeData {
767            kind: UiNodeKind::Text { text: text.into() },
768            key: None,
769            style: default_node_style(),
770            part_styles: BTreeMap::new(),
771            source: None,
772            component_root: None,
773            attributes: BTreeMap::new(),
774            handlers: BTreeMap::new(),
775            handler_payloads: BTreeMap::new(),
776            motions: Vec::new(),
777            exit_motions: Vec::new(),
778            progress_motions: Vec::new(),
779            timelines: Vec::new(),
780            signal_bindings: BTreeMap::new(),
781            signal_style: None,
782            table_layout: None,
783            table_column: None,
784            element_ref: None,
785            presentation: Vec::new(),
786            component_snapshot: None,
787        }
788        .into()
789    }
790
791    #[must_use]
792    pub fn rich_text(spans: Vec<Span>) -> Self {
793        let text = spans.iter().map(Span::text).collect::<String>().into();
794        UiNodeData {
795            kind: UiNodeKind::RichText { text, spans },
796            key: None,
797            style: default_node_style(),
798            part_styles: BTreeMap::new(),
799            source: None,
800            component_root: None,
801            attributes: BTreeMap::new(),
802            handlers: BTreeMap::new(),
803            handler_payloads: BTreeMap::new(),
804            motions: Vec::new(),
805            exit_motions: Vec::new(),
806            progress_motions: Vec::new(),
807            timelines: Vec::new(),
808            signal_bindings: BTreeMap::new(),
809            signal_style: None,
810            table_layout: None,
811            table_column: None,
812            element_ref: None,
813            presentation: Vec::new(),
814            component_snapshot: None,
815        }
816        .into()
817    }
818
819    #[must_use]
820    pub fn canvas(scene: crate::CanvasScene) -> Self {
821        let mut node = Self::text("");
822        node.kind = UiNodeKind::Canvas { scene };
823        node
824    }
825
826    /// Construct a validated self-contained inline SVG atom.
827    ///
828    /// # Errors
829    ///
830    /// Returns [`crate::InlineSvgError`] for active, external, or oversized
831    /// markup.
832    pub fn svg(source: impl Into<String>) -> Result<Self, crate::InlineSvgError> {
833        let mut node = Self::text("");
834        node.kind = UiNodeKind::Svg {
835            source: crate::InlineSvg::new(source)?,
836        };
837        Ok(node)
838    }
839
840    #[must_use]
841    pub fn box_node(children: Vec<Self>) -> Self {
842        UiNodeData {
843            kind: UiNodeKind::Box { children },
844            key: None,
845            style: default_node_style(),
846            part_styles: BTreeMap::new(),
847            source: None,
848            component_root: None,
849            attributes: BTreeMap::new(),
850            handlers: BTreeMap::new(),
851            handler_payloads: BTreeMap::new(),
852            motions: Vec::new(),
853            exit_motions: Vec::new(),
854            progress_motions: Vec::new(),
855            timelines: Vec::new(),
856            signal_bindings: BTreeMap::new(),
857            signal_style: None,
858            table_layout: None,
859            table_column: None,
860            element_ref: None,
861            presentation: Vec::new(),
862            component_snapshot: None,
863        }
864        .into()
865    }
866
867    #[must_use]
868    pub fn fragment(children: Vec<Self>) -> Self {
869        let mut node = Self::box_node(Vec::new());
870        node.kind = UiNodeKind::Fragment { children };
871        node
872    }
873
874    #[must_use]
875    pub fn column(children: Vec<Self>) -> Self {
876        Self::box_node(children).with_style(&Style::new().flex_col())
877    }
878
879    #[must_use]
880    pub fn row(children: Vec<Self>) -> Self {
881        Self::box_node(children).with_style(&Style::new().flex_row())
882    }
883
884    #[must_use]
885    pub fn custom(primitive: PrimitiveNode) -> Self {
886        let key = primitive.key.as_ref().map(NodeKey::new);
887        UiNodeData {
888            kind: UiNodeKind::Custom { primitive },
889            key,
890            style: default_node_style(),
891            part_styles: BTreeMap::new(),
892            source: None,
893            component_root: None,
894            attributes: BTreeMap::new(),
895            handlers: BTreeMap::new(),
896            handler_payloads: BTreeMap::new(),
897            motions: Vec::new(),
898            exit_motions: Vec::new(),
899            progress_motions: Vec::new(),
900            timelines: Vec::new(),
901            signal_bindings: BTreeMap::new(),
902            signal_style: None,
903            table_layout: None,
904            table_column: None,
905            element_ref: None,
906            presentation: Vec::new(),
907            component_snapshot: None,
908        }
909        .into()
910    }
911
912    #[must_use]
913    pub fn error_boundary(child: Self, fallback: Self) -> Self {
914        UiNodeData {
915            kind: UiNodeKind::ErrorBoundary {
916                child: Box::new(child),
917                fallback: Box::new(fallback),
918            },
919            key: None,
920            style: default_node_style(),
921            part_styles: BTreeMap::new(),
922            source: None,
923            component_root: None,
924            attributes: BTreeMap::new(),
925            handlers: BTreeMap::new(),
926            handler_payloads: BTreeMap::new(),
927            motions: Vec::new(),
928            exit_motions: Vec::new(),
929            progress_motions: Vec::new(),
930            timelines: Vec::new(),
931            signal_bindings: BTreeMap::new(),
932            signal_style: None,
933            table_layout: None,
934            table_column: None,
935            element_ref: None,
936            presentation: Vec::new(),
937            component_snapshot: None,
938        }
939        .into()
940    }
941
942    #[must_use]
943    pub fn image(handle: OpaqueHandle) -> Self {
944        Self::image_source(ImageSourceSpec::Handle(handle))
945    }
946
947    #[must_use]
948    pub fn asset_image(asset: AssetId) -> Self {
949        Self::image_source(ImageSourceSpec::Asset(asset))
950    }
951
952    fn image_source(source: ImageSourceSpec) -> Self {
953        UiNodeData {
954            kind: UiNodeKind::Image { source },
955            key: None,
956            style: default_node_style(),
957            part_styles: BTreeMap::new(),
958            source: None,
959            component_root: None,
960            attributes: BTreeMap::new(),
961            handlers: BTreeMap::new(),
962            handler_payloads: BTreeMap::new(),
963            motions: Vec::new(),
964            exit_motions: Vec::new(),
965            progress_motions: Vec::new(),
966            timelines: Vec::new(),
967            signal_bindings: BTreeMap::new(),
968            signal_style: None,
969            table_layout: None,
970            table_column: None,
971            element_ref: None,
972            presentation: Vec::new(),
973            component_snapshot: None,
974        }
975        .into()
976    }
977
978    #[must_use]
979    pub fn directional_image(left_to_right: OpaqueHandle, right_to_left: OpaqueHandle) -> Self {
980        Self::directional_image_sources(
981            ImageSourceSpec::Handle(left_to_right),
982            ImageSourceSpec::Handle(right_to_left),
983        )
984    }
985
986    #[must_use]
987    pub fn directional_asset_image(left_to_right: AssetId, right_to_left: AssetId) -> Self {
988        Self::directional_image_sources(
989            ImageSourceSpec::Asset(left_to_right),
990            ImageSourceSpec::Asset(right_to_left),
991        )
992    }
993
994    fn directional_image_sources(
995        left_to_right: ImageSourceSpec,
996        right_to_left: ImageSourceSpec,
997    ) -> Self {
998        UiNodeData {
999            kind: UiNodeKind::DirectionalImage {
1000                left_to_right,
1001                right_to_left,
1002            },
1003            key: None,
1004            style: default_node_style(),
1005            part_styles: BTreeMap::new(),
1006            source: None,
1007            component_root: None,
1008            attributes: BTreeMap::new(),
1009            handlers: BTreeMap::new(),
1010            handler_payloads: BTreeMap::new(),
1011            motions: Vec::new(),
1012            exit_motions: Vec::new(),
1013            progress_motions: Vec::new(),
1014            timelines: Vec::new(),
1015            signal_bindings: BTreeMap::new(),
1016            signal_style: None,
1017            table_layout: None,
1018            table_column: None,
1019            element_ref: None,
1020            presentation: Vec::new(),
1021            component_snapshot: None,
1022        }
1023        .into()
1024    }
1025
1026    #[must_use]
1027    pub fn overlay(trigger: Self, content: Self, spec: OverlayNodeSpec) -> Self {
1028        UiNodeData {
1029            kind: UiNodeKind::Overlay {
1030                trigger: Box::new(trigger),
1031                content: Box::new(content),
1032                spec,
1033            },
1034            key: None,
1035            style: default_node_style(),
1036            part_styles: BTreeMap::new(),
1037            source: None,
1038            component_root: None,
1039            attributes: BTreeMap::new(),
1040            handlers: BTreeMap::new(),
1041            handler_payloads: BTreeMap::new(),
1042            motions: Vec::new(),
1043            exit_motions: Vec::new(),
1044            progress_motions: Vec::new(),
1045            timelines: Vec::new(),
1046            signal_bindings: BTreeMap::new(),
1047            signal_style: None,
1048            table_layout: None,
1049            table_column: None,
1050            element_ref: None,
1051            presentation: Vec::new(),
1052            component_snapshot: None,
1053        }
1054        .into()
1055    }
1056
1057    #[must_use]
1058    pub fn layer(content: Self, spec: LayerNodeSpec) -> Self {
1059        let key = spec.id.as_str().to_owned();
1060        UiNodeData {
1061            kind: UiNodeKind::Layer {
1062                content: Box::new(content),
1063                spec,
1064            },
1065            key: Some(NodeKey::new(key)),
1066            style: default_node_style(),
1067            part_styles: BTreeMap::new(),
1068            source: None,
1069            component_root: None,
1070            attributes: BTreeMap::new(),
1071            handlers: BTreeMap::new(),
1072            handler_payloads: BTreeMap::new(),
1073            motions: Vec::new(),
1074            exit_motions: Vec::new(),
1075            progress_motions: Vec::new(),
1076            timelines: Vec::new(),
1077            signal_bindings: BTreeMap::new(),
1078            signal_style: None,
1079            table_layout: None,
1080            table_column: None,
1081            element_ref: None,
1082            presentation: Vec::new(),
1083            component_snapshot: None,
1084        }
1085        .into()
1086    }
1087
1088    #[must_use]
1089    pub fn virtual_collection(spec: crate::VirtualCollectionNodeSpec) -> Self {
1090        let key = spec.id.key.clone();
1091        UiNodeData {
1092            kind: UiNodeKind::VirtualCollection { spec },
1093            key: Some(NodeKey::new(key)),
1094            style: default_node_style(),
1095            part_styles: BTreeMap::new(),
1096            source: None,
1097            component_root: None,
1098            attributes: BTreeMap::new(),
1099            handlers: BTreeMap::new(),
1100            handler_payloads: BTreeMap::new(),
1101            motions: Vec::new(),
1102            exit_motions: Vec::new(),
1103            progress_motions: Vec::new(),
1104            timelines: Vec::new(),
1105            signal_bindings: BTreeMap::new(),
1106            signal_style: None,
1107            table_layout: None,
1108            table_column: None,
1109            element_ref: None,
1110            presentation: Vec::new(),
1111            component_snapshot: None,
1112        }
1113        .into()
1114    }
1115
1116    fn apply_presentation_mutation(&mut self, mutation: NodePresentationMutation) {
1117        if self.component_root.is_some()
1118            && let Some(snapshot) = self.component_snapshot.clone()
1119        {
1120            snapshot.activate(self);
1121        }
1122        mutation.apply(self);
1123        if self.component_root.is_some() {
1124            self.presentation.push(mutation);
1125        }
1126    }
1127
1128    fn restore_presentation(&mut self, presentation: Vec<NodePresentationMutation>) {
1129        for mutation in &presentation {
1130            mutation.apply(self);
1131        }
1132        self.presentation.extend(presentation);
1133    }
1134
1135    #[must_use]
1136    pub fn with_key(mut self, key: impl Into<ImmutableString>) -> Self {
1137        self.apply_presentation_mutation(NodePresentationMutation::Key(NodeKey::new(key)));
1138        self
1139    }
1140
1141    #[must_use]
1142    pub fn with_style(mut self, style: &Style) -> Self {
1143        self.apply_presentation_mutation(NodePresentationMutation::Style(Rc::new(style.clone())));
1144        self
1145    }
1146
1147    /// Bind one approved hot property to a component-scoped native signal.
1148    ///
1149    /// # Errors
1150    ///
1151    /// Returns a property/type mismatch without changing the node.
1152    pub fn with_signal_binding(
1153        mut self,
1154        property: crate::SignalProperty,
1155        signal: crate::NativeSignal,
1156    ) -> Result<Self, crate::SignalError> {
1157        let expected = property.signal_kind();
1158        let actual = signal.id().kind();
1159        if expected != actual {
1160            return Err(crate::SignalError::InvalidBinding {
1161                property,
1162                expected,
1163                actual,
1164            });
1165        }
1166        self.apply_presentation_mutation(NodePresentationMutation::Signal(property, signal));
1167        Ok(self)
1168    }
1169
1170    /// Merge `states[value]` into this node's style, where `value` is the
1171    /// current value of a string signal.
1172    ///
1173    /// # Errors
1174    ///
1175    /// Returns [`crate::SignalError::TypeMismatch`] for a signal that is not a
1176    /// string signal.
1177    pub fn with_signal_style(
1178        mut self,
1179        signal: crate::NativeSignal,
1180        states: BTreeMap<String, Style>,
1181    ) -> Result<Self, crate::SignalError> {
1182        if signal.id().kind() != crate::SignalKind::String {
1183            return Err(crate::SignalError::TypeMismatch {
1184                expected: crate::SignalKind::String,
1185                actual: signal.id().kind(),
1186            });
1187        }
1188        self.apply_presentation_mutation(NodePresentationMutation::SignalStyle(Rc::new(
1189            SignalStyle { signal, states },
1190        )));
1191        Ok(self)
1192    }
1193
1194    #[must_use]
1195    pub fn signal_style(&self) -> Option<&SignalStyle> {
1196        self.signal_style.as_deref()
1197    }
1198
1199    #[must_use]
1200    pub fn with_element_ref(mut self, reference: crate::ElementRef) -> Self {
1201        self.apply_presentation_mutation(NodePresentationMutation::ElementRef(reference));
1202        self
1203    }
1204
1205    #[must_use]
1206    pub fn with_scrollbars(mut self, spec: crate::ScrollbarSpec) -> Self {
1207        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1208            "scrollbar_horizontal".to_owned(),
1209            UiValue::String(spec.horizontal().as_str().to_owned()),
1210        ));
1211        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1212            "scrollbar_vertical".to_owned(),
1213            UiValue::String(spec.vertical().as_str().to_owned()),
1214        ));
1215        self
1216    }
1217
1218    #[must_use]
1219    pub fn with_part_styles(mut self, styles: BTreeMap<String, Style>) -> Self {
1220        self.apply_presentation_mutation(NodePresentationMutation::PartStyles(Rc::new(styles)));
1221        self
1222    }
1223
1224    pub(crate) fn with_owned_part_styles(mut self, styles: BTreeMap<String, Style>) -> Self {
1225        self.part_styles.extend(styles);
1226        self
1227    }
1228
1229    #[must_use]
1230    pub fn with_part_style(mut self, part: impl Into<String>, style: Style) -> Self {
1231        self.apply_presentation_mutation(NodePresentationMutation::PartStyles(Rc::new(
1232            BTreeMap::from([(part.into(), style)]),
1233        )));
1234        self
1235    }
1236
1237    #[must_use]
1238    pub fn with_source(mut self, source: SourceLocation) -> Self {
1239        self.source = Some(source);
1240        self
1241    }
1242
1243    #[cfg(test)]
1244    pub(crate) fn with_component_root(mut self, component: ComponentInstancePath) -> Self {
1245        self.component_root = Some(component);
1246        self.presentation.clear();
1247        self.component_snapshot = None;
1248        self
1249    }
1250
1251    pub(crate) fn with_component_root_snapshot(
1252        mut self,
1253        component: ComponentInstancePath,
1254        snapshot: ComponentOwnedSnapshotRef,
1255    ) -> Self {
1256        self.component_root = Some(component);
1257        self.presentation.clear();
1258        self.component_snapshot = Some(snapshot);
1259        self
1260    }
1261
1262    #[must_use]
1263    pub fn component_root(&self) -> Option<&ComponentInstancePath> {
1264        self.component_root.as_ref()
1265    }
1266
1267    pub(crate) fn replace_component_subtree(
1268        &mut self,
1269        component: &ComponentInstancePath,
1270        replacement: Self,
1271    ) -> bool {
1272        if self.component_root.as_ref() == Some(component) {
1273            let presentation = std::mem::take(&mut self.presentation);
1274            *self = replacement;
1275            self.restore_presentation(presentation);
1276            return true;
1277        }
1278        match &mut self.kind {
1279            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1280                replace_in_nodes(children.iter_mut(), component, &replacement)
1281            }
1282            UiNodeKind::Custom { primitive } => {
1283                for (_, value) in primitive.props.iter_mut() {
1284                    let replaced = match value {
1285                        crate::PrimitiveValue::Node(node) => {
1286                            node.replace_component_subtree(component, replacement.clone())
1287                        }
1288                        crate::PrimitiveValue::Nodes(nodes) => {
1289                            replace_in_nodes(nodes.iter_mut(), component, &replacement)
1290                        }
1291                        _ => false,
1292                    };
1293                    if replaced {
1294                        return true;
1295                    }
1296                }
1297                false
1298            }
1299            UiNodeKind::Overlay {
1300                trigger, content, ..
1301            }
1302            | UiNodeKind::ErrorBoundary {
1303                child: trigger,
1304                fallback: content,
1305            } => {
1306                trigger.replace_component_subtree(component, replacement.clone())
1307                    || content.replace_component_subtree(component, replacement)
1308            }
1309            UiNodeKind::Layer { content, .. } => {
1310                content.replace_component_subtree(component, replacement)
1311            }
1312            UiNodeKind::VirtualCollection { spec } => {
1313                replace_in_nodes(spec.realized.values_mut(), component, &replacement)
1314            }
1315            UiNodeKind::Text { .. }
1316            | UiNodeKind::RichText { .. }
1317            | UiNodeKind::Canvas { .. }
1318            | UiNodeKind::Svg { .. }
1319            | UiNodeKind::Image { .. }
1320            | UiNodeKind::DirectionalImage { .. } => false,
1321        }
1322    }
1323
1324    pub(crate) fn activate_component_snapshots(&mut self) {
1325        if self.component_root.is_some()
1326            && let Some(snapshot) = self.component_snapshot.clone()
1327        {
1328            snapshot.activate(self);
1329        }
1330        self.visit_component_children_mut(Self::activate_component_snapshots);
1331    }
1332
1333    pub(crate) fn hydrate_component_subtrees(&mut self) {
1334        if let Some(current) = self
1335            .component_snapshot
1336            .as_ref()
1337            .and_then(ComponentOwnedSnapshotRef::current)
1338        {
1339            let presentation = std::mem::take(&mut self.presentation);
1340            *self = current.as_ref().clone();
1341            self.restore_presentation(presentation);
1342        }
1343        self.visit_component_children_mut(Self::hydrate_component_subtrees);
1344    }
1345
1346    fn visit_component_children_mut(&mut self, mut visit: impl FnMut(&mut Self)) {
1347        match &mut self.kind {
1348            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1349                for child in children {
1350                    visit(child);
1351                }
1352            }
1353            UiNodeKind::Custom { primitive } => {
1354                for (_, value) in primitive.props.iter_mut() {
1355                    match value {
1356                        crate::PrimitiveValue::Node(node) => {
1357                            visit(node);
1358                        }
1359                        crate::PrimitiveValue::Nodes(nodes) => {
1360                            for node in nodes {
1361                                visit(node);
1362                            }
1363                        }
1364                        crate::PrimitiveValue::Data(_)
1365                        | crate::PrimitiveValue::Callback(_)
1366                        | crate::PrimitiveValue::Style(_)
1367                        | crate::PrimitiveValue::Length(_)
1368                        | crate::PrimitiveValue::Asset(_)
1369                        | crate::PrimitiveValue::Signal(_)
1370                        | crate::PrimitiveValue::Ref(_)
1371                        | crate::PrimitiveValue::Document(_) => {}
1372                        #[cfg(feature = "charts")]
1373                        crate::PrimitiveValue::ChartData(_) => {}
1374                    }
1375                }
1376            }
1377            UiNodeKind::Overlay {
1378                trigger, content, ..
1379            } => {
1380                visit(trigger);
1381                visit(content);
1382            }
1383            UiNodeKind::Layer { content, .. } => {
1384                visit(content);
1385            }
1386            UiNodeKind::VirtualCollection { spec } => {
1387                for item in spec.realized.values_mut() {
1388                    visit(item);
1389                }
1390            }
1391            UiNodeKind::ErrorBoundary { child, fallback } => {
1392                visit(child);
1393                visit(fallback);
1394            }
1395            UiNodeKind::Text { .. }
1396            | UiNodeKind::RichText { .. }
1397            | UiNodeKind::Canvas { .. }
1398            | UiNodeKind::Svg { .. }
1399            | UiNodeKind::Image { .. }
1400            | UiNodeKind::DirectionalImage { .. } => {}
1401        }
1402    }
1403
1404    pub(crate) fn virtual_collection_items(
1405        &self,
1406        id: &crate::VirtualCollectionId,
1407    ) -> Option<&BTreeMap<usize, UiNode>> {
1408        self.virtual_collection_spec(id).map(|spec| &spec.realized)
1409    }
1410
1411    pub(crate) fn virtual_read_contributions(
1412        &self,
1413    ) -> std::collections::BTreeSet<crate::read_dependency::ReadContribution> {
1414        let mut active = std::collections::BTreeSet::new();
1415        self.collect_virtual_read_contributions(&mut active);
1416        active
1417    }
1418
1419    fn collect_virtual_read_contributions(
1420        &self,
1421        active: &mut std::collections::BTreeSet<crate::read_dependency::ReadContribution>,
1422    ) {
1423        if let UiNodeKind::VirtualCollection { spec } = &self.kind {
1424            for index in spec.realized.keys() {
1425                if let Some(key) = spec.data.key(*index) {
1426                    active.insert(crate::read_dependency::ReadContribution::VirtualItem {
1427                        collection: spec.id.clone(),
1428                        key: key.to_owned(),
1429                    });
1430                }
1431            }
1432        }
1433        for (_, children) in self.retained_child_groups() {
1434            for child in children {
1435                child.collect_virtual_read_contributions(active);
1436            }
1437        }
1438    }
1439
1440    pub(crate) fn virtual_collection_spec(
1441        &self,
1442        id: &crate::VirtualCollectionId,
1443    ) -> Option<&crate::VirtualCollectionNodeSpec> {
1444        match &self.kind {
1445            UiNodeKind::VirtualCollection { spec } if &spec.id == id => Some(spec),
1446            UiNodeKind::VirtualCollection { spec } => spec
1447                .realized
1448                .values()
1449                .find_map(|child| child.virtual_collection_spec(id)),
1450            UiNodeKind::Custom { primitive } => {
1451                primitive.props.iter().find_map(|(_, value)| match value {
1452                    crate::PrimitiveValue::Node(node) => node.virtual_collection_spec(id),
1453                    crate::PrimitiveValue::Nodes(nodes) => nodes
1454                        .iter()
1455                        .find_map(|node| node.virtual_collection_spec(id)),
1456                    _ => None,
1457                })
1458            }
1459            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => children
1460                .iter()
1461                .find_map(|child| child.virtual_collection_spec(id)),
1462            UiNodeKind::Overlay {
1463                trigger, content, ..
1464            }
1465            | UiNodeKind::ErrorBoundary {
1466                child: trigger,
1467                fallback: content,
1468            } => trigger
1469                .virtual_collection_spec(id)
1470                .or_else(|| content.virtual_collection_spec(id)),
1471            UiNodeKind::Layer { content, .. } => content.virtual_collection_spec(id),
1472            _ => None,
1473        }
1474    }
1475
1476    pub(crate) fn replace_virtual_collection_items(
1477        &mut self,
1478        id: &crate::VirtualCollectionId,
1479        items: BTreeMap<usize, UiNode>,
1480    ) -> bool {
1481        self.replace_virtual_collection_items_in(id, &mut Some(items))
1482    }
1483
1484    /// Move the realized items into the matching collection. Nodes passed on
1485    /// the way down receive nothing: cloning the whole realized map for every
1486    /// visited child made each scroll step cost (nodes before the collection)
1487    /// x (realized items).
1488    fn replace_virtual_collection_items_in(
1489        &mut self,
1490        id: &crate::VirtualCollectionId,
1491        items: &mut Option<BTreeMap<usize, UiNode>>,
1492    ) -> bool {
1493        match &mut self.kind {
1494            UiNodeKind::VirtualCollection { spec } if &spec.id == id => {
1495                if let Some(items) = items.take() {
1496                    spec.realized = items;
1497                }
1498                true
1499            }
1500            UiNodeKind::VirtualCollection { spec } => spec
1501                .realized
1502                .values_mut()
1503                .any(|child| child.replace_virtual_collection_items_in(id, items)),
1504            UiNodeKind::Custom { primitive } => {
1505                primitive.props.iter_mut().any(|(_, value)| match value {
1506                    crate::PrimitiveValue::Node(node) => {
1507                        node.replace_virtual_collection_items_in(id, items)
1508                    }
1509                    crate::PrimitiveValue::Nodes(nodes) => nodes
1510                        .iter_mut()
1511                        .any(|node| node.replace_virtual_collection_items_in(id, items)),
1512                    _ => false,
1513                })
1514            }
1515            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => children
1516                .iter_mut()
1517                .any(|child| child.replace_virtual_collection_items_in(id, items)),
1518            UiNodeKind::Overlay {
1519                trigger, content, ..
1520            }
1521            | UiNodeKind::ErrorBoundary {
1522                child: trigger,
1523                fallback: content,
1524            } => {
1525                trigger.replace_virtual_collection_items_in(id, items)
1526                    || content.replace_virtual_collection_items_in(id, items)
1527            }
1528            UiNodeKind::Layer { content, .. } => {
1529                content.replace_virtual_collection_items_in(id, items)
1530            }
1531            _ => false,
1532        }
1533    }
1534
1535    /// Set environment values inherited by this node's subtree, merging with
1536    /// values set earlier on the same node. Names and values are `snake_case`
1537    /// strings; whether they are declared is decided by the active token base.
1538    ///
1539    /// # Errors
1540    ///
1541    /// Returns a message for non-string or malformed entries, or more than
1542    /// [`crate::token::MAX_ENVIRONMENT_VALUES`] names.
1543    pub fn with_environment(self, values: &Map) -> Result<Self, String> {
1544        let mut merged = match self.attributes.get("environment") {
1545            Some(UiValue::Map(existing)) => existing.clone(),
1546            _ => BTreeMap::new(),
1547        };
1548        for (name, value) in values {
1549            let value = value
1550                .clone()
1551                .into_string()
1552                .map_err(|_| format!("environment value `{name}` must be a string"))?;
1553            if !crate::token::valid_token_segment(name)
1554                || !crate::token::valid_token_segment(&value)
1555            {
1556                return Err(format!(
1557                    "environment `{name}: {value}` must use snake_case names and values"
1558                ));
1559            }
1560            merged.insert(name.to_string(), UiValue::String(value));
1561        }
1562        if merged.len() > crate::token::MAX_ENVIRONMENT_VALUES {
1563            return Err(format!(
1564                "a node can set at most {} environment values",
1565                crate::token::MAX_ENVIRONMENT_VALUES
1566            ));
1567        }
1568        Ok(self.with_attribute("environment", UiValue::Map(merged)))
1569    }
1570
1571    #[must_use]
1572    pub fn with_attribute(mut self, name: impl Into<String>, value: UiValue) -> Self {
1573        self.apply_presentation_mutation(NodePresentationMutation::Attribute(name.into(), value));
1574        self
1575    }
1576
1577    #[must_use]
1578    pub fn with_handler(
1579        mut self,
1580        event: impl Into<String>,
1581        handler: impl Into<UiEventHandler>,
1582    ) -> Self {
1583        self.apply_presentation_mutation(NodePresentationMutation::Handler(
1584            event.into(),
1585            UiEventBinding::new(crate::EventPhase::Target, handler),
1586        ));
1587        self
1588    }
1589
1590    #[must_use]
1591    pub fn with_handler_phase(
1592        mut self,
1593        event: impl Into<String>,
1594        phase: crate::EventPhase,
1595        handler: impl Into<UiEventHandler>,
1596    ) -> Self {
1597        self.apply_presentation_mutation(NodePresentationMutation::Handler(
1598            event.into(),
1599            UiEventBinding::new(phase, handler),
1600        ));
1601        self
1602    }
1603
1604    /// Append a handler that receives `value` as its payload; the node's other
1605    /// handlers for `event` keep their own payloads.
1606    #[must_use]
1607    pub fn with_value_handler(
1608        mut self,
1609        event: impl Into<String>,
1610        handler: impl Into<UiEventHandler>,
1611        value: UiValue,
1612    ) -> Self {
1613        self.apply_presentation_mutation(NodePresentationMutation::Handler(
1614            event.into(),
1615            UiEventBinding::new(crate::EventPhase::Target, handler).with_value(value),
1616        ));
1617        self
1618    }
1619
1620    #[must_use]
1621    pub fn with_host_handler(self, event: impl Into<String>, callback: HostCallback) -> Self {
1622        self.with_handler(event, UiEventHandler::Host(callback))
1623    }
1624
1625    #[must_use]
1626    pub fn with_handler_payload(mut self, event: impl Into<String>, payload: UiValue) -> Self {
1627        self.apply_presentation_mutation(NodePresentationMutation::HandlerPayload(
1628            event.into(),
1629            payload,
1630        ));
1631        self
1632    }
1633
1634    #[must_use]
1635    pub fn with_motion(mut self, animation: MotionSource) -> Self {
1636        self.apply_presentation_mutation(NodePresentationMutation::Motion(animation));
1637        self
1638    }
1639
1640    #[must_use]
1641    pub fn with_exit_motion(mut self, motion: MotionSource) -> Self {
1642        self.apply_presentation_mutation(NodePresentationMutation::ExitMotion(motion));
1643        self
1644    }
1645
1646    #[must_use]
1647    pub fn with_progress_motion(mut self, binding: crate::MotionProgressBinding) -> Self {
1648        self.apply_presentation_mutation(NodePresentationMutation::ProgressMotion(binding));
1649        self
1650    }
1651
1652    #[must_use]
1653    pub fn with_motion_replay_key(mut self, key: impl Into<String>) -> Self {
1654        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1655            "motion_replay_key".to_owned(),
1656            UiValue::String(key.into()),
1657        ));
1658        self
1659    }
1660
1661    #[must_use]
1662    pub fn with_motion_particle_count(mut self, count: usize) -> Self {
1663        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1664            "motion_particle_count".to_owned(),
1665            UiValue::Integer(INT::try_from(count).unwrap_or(INT::MAX)),
1666        ));
1667        self
1668    }
1669
1670    #[must_use]
1671    pub fn with_timeline(mut self, timeline: crate::MotionTimeline) -> Self {
1672        self.apply_presentation_mutation(NodePresentationMutation::Timeline(Box::new(timeline)));
1673        self
1674    }
1675
1676    /// Opt this node into committed-geometry layout motion.
1677    #[must_use]
1678    pub fn with_layout_motion(mut self, duration_ms: u64, easing: crate::MotionEasing) -> Self {
1679        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1680            "layout_motion_duration_ms".to_owned(),
1681            UiValue::Integer(INT::try_from(duration_ms).unwrap_or(INT::MAX)),
1682        ));
1683        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1684            "layout_motion_easing".to_owned(),
1685            UiValue::String(easing.as_str().to_owned()),
1686        ));
1687        self
1688    }
1689
1690    /// Assign a same-window shared-layout identity to this real node.
1691    #[must_use]
1692    pub fn with_shared_layout(mut self, group: impl Into<String>, id: impl Into<String>) -> Self {
1693        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1694            "shared_layout_group".to_owned(),
1695            UiValue::String(group.into()),
1696        ));
1697        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1698            "shared_layout_id".to_owned(),
1699            UiValue::String(id.into()),
1700        ));
1701        self
1702    }
1703
1704    #[must_use]
1705    pub fn with_shared_layout_id(mut self, id: impl Into<String>) -> Self {
1706        self.apply_presentation_mutation(NodePresentationMutation::Attribute(
1707            "shared_layout_id".to_owned(),
1708            UiValue::String(id.into()),
1709        ));
1710        self
1711    }
1712
1713    #[must_use]
1714    pub fn handlers(&self) -> &BTreeMap<String, Vec<UiEventBinding>> {
1715        &self.handlers
1716    }
1717
1718    #[must_use]
1719    pub fn handler(&self, event: &str) -> Option<&UiEventHandler> {
1720        self.handlers
1721            .get(event)?
1722            .iter()
1723            .find(|binding| binding.phase() == crate::EventPhase::Target)
1724            .map(UiEventBinding::handler)
1725    }
1726
1727    #[must_use]
1728    pub fn event_handlers(&self, event: &str) -> &[UiEventBinding] {
1729        self.handlers.get(event).map_or(&[], Vec::as_slice)
1730    }
1731
1732    /// The payload set for `event` with [`Self::with_handler_payload`], else
1733    /// the value of the last handler declared with one.
1734    #[must_use]
1735    pub fn handler_payload(&self, event: &str) -> Option<&UiValue> {
1736        self.handler_payloads.get(event).or_else(|| {
1737            self.event_handlers(event)
1738                .iter()
1739                .rev()
1740                .find_map(UiEventBinding::value)
1741        })
1742    }
1743
1744    /// The payload set for `event` on the node itself, which handlers without
1745    /// their own value receive.
1746    #[must_use]
1747    pub(crate) fn node_payload(&self, event: &str) -> Option<&UiValue> {
1748        self.handler_payloads.get(event)
1749    }
1750
1751    #[must_use]
1752    pub(crate) fn handler_payloads(&self) -> &BTreeMap<String, UiValue> {
1753        &self.handler_payloads
1754    }
1755
1756    #[must_use]
1757    pub fn motions(&self) -> &[MotionSource] {
1758        &self.motions
1759    }
1760
1761    #[must_use]
1762    pub fn exit_motions(&self) -> &[MotionSource] {
1763        &self.exit_motions
1764    }
1765
1766    #[must_use]
1767    pub fn progress_motions(&self) -> &[crate::MotionProgressBinding] {
1768        &self.progress_motions
1769    }
1770
1771    pub(crate) fn motion_ghost(&self) -> Option<Self> {
1772        self.motion_ghost_node(true)
1773    }
1774
1775    fn motion_ghost_node(&self, root: bool) -> Option<Self> {
1776        let mut ghost = self.clone();
1777        ghost.handlers.clear();
1778        ghost.handler_payloads.clear();
1779        ghost.signal_bindings.clear();
1780        ghost.element_ref = None;
1781        ghost.component_root = None;
1782        ghost.component_snapshot = None;
1783        ghost.presentation.clear();
1784        ghost.motions.clear();
1785        ghost.exit_motions.clear();
1786        ghost.progress_motions.clear();
1787        ghost.timelines.clear();
1788        ghost.attributes.clear();
1789        ghost
1790            .attributes
1791            .insert("motion_ghost".to_owned(), UiValue::Bool(true));
1792        let style = &mut Rc::make_mut(&mut ghost.style).base;
1793        style.hit_test = None;
1794        style.cursor = None;
1795        if root {
1796            style.margin = crate::LayoutEdgeLengths::default();
1797            style.position = None;
1798            style.top = None;
1799            style.right = None;
1800            style.bottom = None;
1801            style.left = None;
1802            style.inset_start = None;
1803            style.inset_end = None;
1804            style.translate_x = None;
1805            style.translate_y = None;
1806            style.align_self = None;
1807            style.flex_grow = None;
1808            style.flex_grow_weight = None;
1809            style.flex_shrink = None;
1810            style.flex_basis = None;
1811        }
1812        match &mut ghost.kind {
1813            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1814                *children = children
1815                    .iter()
1816                    .map(|child| child.motion_ghost_node(false))
1817                    .collect::<Option<Vec<_>>>()?;
1818            }
1819            UiNodeKind::Text { .. }
1820            | UiNodeKind::RichText { .. }
1821            | UiNodeKind::Canvas { .. }
1822            | UiNodeKind::Svg { .. }
1823            | UiNodeKind::Image { .. }
1824            | UiNodeKind::DirectionalImage { .. } => {}
1825            UiNodeKind::Custom { .. }
1826            | UiNodeKind::Overlay { .. }
1827            | UiNodeKind::Layer { .. }
1828            | UiNodeKind::VirtualCollection { .. }
1829            | UiNodeKind::ErrorBoundary { .. } => return None,
1830        }
1831        Some(ghost)
1832    }
1833
1834    #[must_use]
1835    pub fn timelines(&self) -> &[crate::MotionTimeline] {
1836        &self.timelines
1837    }
1838
1839    pub(crate) fn bind_generation(&mut self, generation: ScriptGeneration) {
1840        for bindings in self.handlers.values_mut() {
1841            for binding in bindings {
1842                if let Some(callback) = binding.handler_mut().as_script_mut() {
1843                    callback.bind_generation(generation);
1844                }
1845            }
1846        }
1847        for timeline in &mut self.timelines {
1848            timeline.bind_generation(generation);
1849        }
1850        for mutation in &mut self.presentation {
1851            mutation.bind_generation(generation);
1852        }
1853        match &mut self.kind {
1854            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1855                for child in children {
1856                    child.bind_generation(generation);
1857                }
1858            }
1859            UiNodeKind::ErrorBoundary { child, fallback } => {
1860                child.bind_generation(generation);
1861                fallback.bind_generation(generation);
1862            }
1863            UiNodeKind::Overlay {
1864                trigger, content, ..
1865            } => {
1866                trigger.bind_generation(generation);
1867                content.bind_generation(generation);
1868            }
1869            UiNodeKind::Layer { content, .. } => content.bind_generation(generation),
1870            UiNodeKind::Text { .. }
1871            | UiNodeKind::RichText { .. }
1872            | UiNodeKind::Canvas { .. }
1873            | UiNodeKind::Svg { .. }
1874            | UiNodeKind::Custom { .. }
1875            | UiNodeKind::Image { .. }
1876            | UiNodeKind::DirectionalImage { .. } => {}
1877            UiNodeKind::VirtualCollection { spec } => {
1878                for item in spec.realized.values_mut() {
1879                    item.bind_generation(generation);
1880                }
1881            }
1882        }
1883    }
1884
1885    pub(crate) fn bind_component_scope(
1886        &mut self,
1887        component: &crate::ComponentInstancePath,
1888        incarnation: crate::ComponentIncarnation,
1889        events: &BTreeMap<String, crate::EventSchema>,
1890        native_context: Option<&crate::invocation::ScriptInvocationContext>,
1891    ) {
1892        // A shared-layout group belongs to the instance that named it, so two
1893        // instances of one component do not share an identity.
1894        if self.attributes.contains_key("shared_layout_group")
1895            && !self.attributes.contains_key("shared_layout_scope")
1896        {
1897            self.attributes.insert(
1898                "shared_layout_scope".to_owned(),
1899                UiValue::String(component.to_string()),
1900            );
1901        }
1902        for bindings in self.handlers.values_mut() {
1903            for binding in bindings {
1904                if let Some(callback) = binding.handler_mut().as_script_mut() {
1905                    callback.bind_component_scope_if_unset(component, incarnation, events.clone());
1906                    if let Some(context) = native_context {
1907                        callback.bind_native_context_if_unset(context.clone());
1908                    }
1909                }
1910            }
1911        }
1912        for timeline in &mut self.timelines {
1913            timeline.bind_component_scope(component, incarnation, events, native_context);
1914        }
1915        for mutation in &mut self.presentation {
1916            mutation.bind_component_scope(component, incarnation, events, native_context);
1917        }
1918        match &mut self.kind {
1919            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1920                for child in children {
1921                    child.bind_component_scope(component, incarnation, events, native_context);
1922                }
1923            }
1924            UiNodeKind::Custom { primitive } => {
1925                primitive.props.bind_component_scope(
1926                    component,
1927                    incarnation,
1928                    events,
1929                    native_context,
1930                );
1931            }
1932            UiNodeKind::ErrorBoundary { child, fallback } => {
1933                child.bind_component_scope(component, incarnation, events, native_context);
1934                fallback.bind_component_scope(component, incarnation, events, native_context);
1935            }
1936            UiNodeKind::Overlay {
1937                trigger,
1938                content,
1939                spec,
1940            } => {
1941                spec.owner.get_or_insert_with(|| component.clone());
1942                trigger.bind_component_scope(component, incarnation, events, native_context);
1943                content.bind_component_scope(component, incarnation, events, native_context);
1944            }
1945            UiNodeKind::Layer { content, spec } => {
1946                spec.owner.get_or_insert_with(|| component.clone());
1947                content.bind_component_scope(component, incarnation, events, native_context);
1948            }
1949            UiNodeKind::VirtualCollection { spec } => {
1950                if spec.inherited_motion_group.is_some() && spec.inherited_motion_scope.is_none() {
1951                    spec.inherited_motion_scope = Some(component.to_string());
1952                }
1953                for item in spec.realized.values_mut() {
1954                    item.bind_component_scope(component, incarnation, events, native_context);
1955                }
1956            }
1957            UiNodeKind::Text { .. }
1958            | UiNodeKind::RichText { .. }
1959            | UiNodeKind::Canvas { .. }
1960            | UiNodeKind::Svg { .. }
1961            | UiNodeKind::Image { .. }
1962            | UiNodeKind::DirectionalImage { .. } => {}
1963        }
1964    }
1965
1966    #[must_use]
1967    pub fn kind(&self) -> &UiNodeKind {
1968        &self.kind
1969    }
1970
1971    #[must_use]
1972    pub fn kind_tag(&self) -> UiNodeKindTag {
1973        match self.kind {
1974            UiNodeKind::Text { .. } | UiNodeKind::RichText { .. } => UiNodeKindTag::Text,
1975            UiNodeKind::Canvas { .. } => UiNodeKindTag::Canvas,
1976            UiNodeKind::Svg { .. } => UiNodeKindTag::Svg,
1977            UiNodeKind::Box { .. } => UiNodeKindTag::Box,
1978            UiNodeKind::Fragment { .. } => UiNodeKindTag::Fragment,
1979            UiNodeKind::Custom { .. } => UiNodeKindTag::Custom,
1980            UiNodeKind::Image { .. } => UiNodeKindTag::Image,
1981            UiNodeKind::DirectionalImage { .. } => UiNodeKindTag::DirectionalImage,
1982            UiNodeKind::Overlay { .. } => UiNodeKindTag::Overlay,
1983            UiNodeKind::Layer { .. } => UiNodeKindTag::Layer,
1984            UiNodeKind::VirtualCollection { .. } => UiNodeKindTag::VirtualCollection,
1985            UiNodeKind::ErrorBoundary { .. } => UiNodeKindTag::ErrorBoundary,
1986        }
1987    }
1988
1989    pub(crate) fn retained_child_groups(&self) -> Vec<(String, Vec<&Self>)> {
1990        match &self.kind {
1991            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
1992                vec![("children".to_owned(), children.iter().collect())]
1993            }
1994            UiNodeKind::Custom { primitive } => primitive
1995                .props
1996                .iter()
1997                .filter_map(|(name, value)| match value {
1998                    crate::PrimitiveValue::Node(node) => {
1999                        Some((format!("prop:{name}"), vec![node.as_ref()]))
2000                    }
2001                    crate::PrimitiveValue::Nodes(nodes) => {
2002                        Some((format!("prop:{name}"), nodes.iter().collect::<Vec<_>>()))
2003                    }
2004                    crate::PrimitiveValue::Data(_)
2005                    | crate::PrimitiveValue::Callback(_)
2006                    | crate::PrimitiveValue::Style(_)
2007                    | crate::PrimitiveValue::Length(_)
2008                    | crate::PrimitiveValue::Asset(_)
2009                    | crate::PrimitiveValue::Signal(_)
2010                    | crate::PrimitiveValue::Ref(_)
2011                    | crate::PrimitiveValue::Document(_) => None,
2012                    #[cfg(feature = "charts")]
2013                    crate::PrimitiveValue::ChartData(_) => None,
2014                })
2015                .collect(),
2016            UiNodeKind::Overlay {
2017                trigger, content, ..
2018            } => vec![
2019                ("trigger".to_owned(), vec![trigger.as_ref()]),
2020                ("content".to_owned(), vec![content.as_ref()]),
2021            ],
2022            UiNodeKind::Layer { content, .. } => {
2023                vec![("content".to_owned(), vec![content.as_ref()])]
2024            }
2025            UiNodeKind::VirtualCollection { spec } => {
2026                vec![("items".to_owned(), spec.realized.values().collect())]
2027            }
2028            UiNodeKind::ErrorBoundary { child, fallback } => vec![
2029                ("child".to_owned(), vec![child.as_ref()]),
2030                ("fallback".to_owned(), vec![fallback.as_ref()]),
2031            ],
2032            UiNodeKind::Text { .. }
2033            | UiNodeKind::RichText { .. }
2034            | UiNodeKind::Canvas { .. }
2035            | UiNodeKind::Svg { .. }
2036            | UiNodeKind::Image { .. }
2037            | UiNodeKind::DirectionalImage { .. } => Vec::new(),
2038        }
2039    }
2040
2041    #[must_use]
2042    pub fn key(&self) -> Option<&NodeKey> {
2043        self.key.as_ref()
2044    }
2045
2046    #[must_use]
2047    pub fn style(&self) -> &Style {
2048        &self.style
2049    }
2050
2051    #[must_use]
2052    pub fn part_style(&self, part: &str) -> Option<&Style> {
2053        self.part_styles.get(part)
2054    }
2055
2056    pub fn part_styles(&self) -> impl Iterator<Item = (&str, &Style)> {
2057        self.part_styles
2058            .iter()
2059            .map(|(name, style)| (name.as_str(), style))
2060    }
2061
2062    #[must_use]
2063    pub fn source(&self) -> Option<&SourceLocation> {
2064        self.source.as_ref()
2065    }
2066
2067    #[must_use]
2068    pub fn attributes(&self) -> &BTreeMap<String, UiValue> {
2069        &self.attributes
2070    }
2071
2072    pub fn signal_bindings(
2073        &self,
2074    ) -> impl ExactSizeIterator<Item = (crate::SignalProperty, &crate::NativeSignal)> {
2075        self.signal_bindings
2076            .iter()
2077            .map(|(property, signal)| (*property, signal))
2078    }
2079
2080    #[must_use]
2081    pub fn element_ref(&self) -> Option<&crate::ElementRef> {
2082        self.element_ref.as_ref()
2083    }
2084
2085    pub(crate) fn table_layout(&self) -> Option<&crate::table_layout::TableLayout> {
2086        self.table_layout.as_deref()
2087    }
2088
2089    pub(crate) fn table_column(&self) -> Option<usize> {
2090        self.table_column
2091    }
2092
2093    /// Apply the one resolved column plan to both header cells and retained
2094    /// virtual rows. Nested tables own their own viewport and plan.
2095    pub(crate) fn resolve_table_layout(
2096        &mut self,
2097        plan: &crate::table_layout::ResolvedColumns,
2098        border: f64,
2099    ) {
2100        self.table_layout = Some(Rc::new(crate::table_layout::TableLayout::Resolved {
2101            extent: plan.extent + border,
2102        }));
2103        if let UiNodeKind::Box { children } = &mut self.kind {
2104            for child in children {
2105                child.resolve_table_columns(plan);
2106                let mut width = Style::new();
2107                width.base.width = Some(crate::Length::Pixels(plan.extent).into());
2108                width.base.flex_shrink = Some(false);
2109                Rc::make_mut(&mut child.style).merge_in_place(&width);
2110            }
2111        }
2112    }
2113
2114    fn resolve_table_columns(&mut self, plan: &crate::table_layout::ResolvedColumns) {
2115        if self.table_layout.is_some() {
2116            return;
2117        }
2118        if let Some(width) = self.table_column.and_then(|index| plan.widths.get(index)) {
2119            let style = Rc::make_mut(&mut self.style);
2120            style.base.width = Some(crate::Length::Pixels(*width).into());
2121            style.base.min_width = style.base.width;
2122            style.base.max_width = style.base.width;
2123            style.base.flex_basis = None;
2124            style.base.flex_grow = Some(false);
2125            style.base.flex_grow_weight = None;
2126            style.base.flex_shrink = Some(false);
2127            self.signal_bindings.remove(&crate::SignalProperty::Width);
2128            self.signal_bindings
2129                .remove(&crate::SignalProperty::WidthOverride);
2130        }
2131        match &mut self.kind {
2132            UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2133                for child in children {
2134                    child.resolve_table_columns(plan);
2135                }
2136            }
2137            UiNodeKind::VirtualCollection { spec } => {
2138                for child in spec.realized.values_mut() {
2139                    child.resolve_table_columns(plan);
2140                }
2141            }
2142            _ => {}
2143        }
2144    }
2145}
2146
2147fn replace_in_nodes<'a>(
2148    nodes: impl IntoIterator<Item = &'a mut UiNode>,
2149    component: &ComponentInstancePath,
2150    replacement: &UiNode,
2151) -> bool {
2152    for node in nodes {
2153        if node.replace_component_subtree(component, replacement.clone()) {
2154            return true;
2155        }
2156    }
2157    false
2158}
2159
2160/// `node.signal_style(signal, #{ state: style() })`.
2161fn node_signal_style(
2162    node: &mut UiNode,
2163    signal: crate::NativeSignal,
2164    states: Map,
2165) -> Result<UiNode, Box<EvalAltResult>> {
2166    let states = states
2167        .into_iter()
2168        .map(|(state, style)| {
2169            style
2170                .try_cast::<Style>()
2171                .map(|style| (state.to_string(), style))
2172                .ok_or_else(|| {
2173                    Box::new(EvalAltResult::ErrorRuntime(
2174                        format!("signal_style state `{state}` must be a style()").into(),
2175                        Position::NONE,
2176                    ))
2177                })
2178        })
2179        .collect::<Result<BTreeMap<_, _>, _>>()?;
2180    node.clone()
2181        .with_signal_style(signal, states)
2182        .map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))
2183}
2184
2185impl CustomType for UiNode {
2186    fn build(mut builder: TypeBuilder<Self>) {
2187        builder
2188            .with_name("UiNode")
2189            .with_fn("with_key", |node: &mut Self, key: ImmutableString| {
2190                node.clone().with_key(key)
2191            })
2192            .with_fn("with_style", |node: &mut Self, style: Style| {
2193                node.clone().with_style(&style)
2194            })
2195            .with_fn(
2196                "with_table_track",
2197                |node: &mut Self, columns: Array, signals: Array| {
2198                    if !matches!(node.kind(), UiNodeKind::Box { .. }) {
2199                        return Err(crate::table_layout::runtime_error(
2200                            "Table track requires a Box container",
2201                        ));
2202                    }
2203                    crate::table_layout::decode_columns(&columns, &signals).map(|columns| {
2204                        let mut result = node.clone();
2205                        result.apply_presentation_mutation(NodePresentationMutation::TableTrack(
2206                            Rc::new(crate::table_layout::TableLayout::Columns(columns)),
2207                        ));
2208                        result
2209                    })
2210                },
2211            )
2212            .with_fn(
2213                "with_table_column",
2214                |node: &mut Self, index: INT| -> Result<Self, Box<EvalAltResult>> {
2215                    let index = usize::try_from(index).map_err(|_| {
2216                        crate::table_layout::runtime_error(
2217                            "Table column index must be non-negative",
2218                        )
2219                    })?;
2220                    if index >= 256 {
2221                        return Err(crate::table_layout::runtime_error(
2222                            "Table column index must be below 256",
2223                        ));
2224                    }
2225                    let mut result = node.clone();
2226                    result
2227                        .apply_presentation_mutation(NodePresentationMutation::TableColumn(index));
2228                    Ok(result)
2229                },
2230            )
2231            .with_fn(
2232                "bind_signal",
2233                |node: &mut Self,
2234                 property: ImmutableString,
2235                 signal: crate::NativeSignal|
2236                 -> Result<Self, Box<EvalAltResult>> {
2237                    let property = crate::SignalProperty::parse(property.as_str())
2238                        .map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
2239                    node.clone()
2240                        .with_signal_binding(property, signal)
2241                        .map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))
2242                },
2243            )
2244            .with_fn("signal_style", node_signal_style)
2245            .with_fn(
2246                "with_ref",
2247                |node: &mut Self, reference: crate::ElementRef| {
2248                    node.clone().with_element_ref(reference)
2249                },
2250            )
2251            .with_fn(
2252                "with_part_style",
2253                |node: &mut Self, part: ImmutableString, style: Style| {
2254                    node.clone().with_part_style(part.to_string(), style)
2255                },
2256            );
2257        register_value_event_methods(&mut builder);
2258        register_raw_event_methods(&mut builder);
2259        register_semantic_event_methods(&mut builder);
2260        register_node_behavior_methods(&mut builder);
2261        register_accessibility_methods(&mut builder);
2262    }
2263}
2264
2265#[allow(clippy::too_many_lines)]
2266fn register_node_behavior_methods(builder: &mut TypeBuilder<UiNode>) {
2267    builder
2268        .with_fn("motion", |node: &mut UiNode, animation: MotionSource| {
2269            node.clone().with_motion(animation)
2270        })
2271        .with_fn("enter_motion", |node: &mut UiNode, motion: MotionSource| {
2272            node.clone().with_motion(motion)
2273        })
2274        .with_fn("exit_motion", |node: &mut UiNode, motion: MotionSource| {
2275            node.clone().with_exit_motion(motion)
2276        })
2277        .with_fn(
2278            "progress_motion",
2279            |node: &mut UiNode, binding: crate::MotionProgressBinding| {
2280                node.clone().with_progress_motion(binding)
2281            },
2282        )
2283        .with_fn(
2284            "motion_replay_key",
2285            |node: &mut UiNode, key: ImmutableString| {
2286                node.clone().with_motion_replay_key(key.to_string())
2287            },
2288        )
2289        .with_fn(
2290            "motion_particle_count",
2291            |node: &mut UiNode, count: INT| -> Result<UiNode, Box<EvalAltResult>> {
2292                let count = usize::try_from(count).map_err(|_| {
2293                    Box::new(EvalAltResult::ErrorRuntime(
2294                        "motion particle count must be non-negative".into(),
2295                        Position::NONE,
2296                    ))
2297                })?;
2298                Ok(node.clone().with_motion_particle_count(count))
2299            },
2300        )
2301        .with_fn(
2302            "timeline",
2303            |node: &mut UiNode, timeline: crate::MotionTimeline| {
2304                node.clone().with_timeline(timeline)
2305            },
2306        )
2307        .with_fn(
2308            "layout_motion",
2309            |node: &mut UiNode,
2310             duration_ms: INT,
2311             easing: ImmutableString|
2312             -> Result<UiNode, Box<EvalAltResult>> {
2313                let duration_ms = u64::try_from(duration_ms).map_err(|_| {
2314                    Box::new(EvalAltResult::ErrorRuntime(
2315                        "layout motion duration must be non-negative".into(),
2316                        Position::NONE,
2317                    ))
2318                })?;
2319                let easing = crate::MotionEasing::parse(easing.as_str()).map_err(|error| {
2320                    Box::new(EvalAltResult::ErrorRuntime(
2321                        error.to_string().into(),
2322                        Position::NONE,
2323                    ))
2324                })?;
2325                Ok(node.clone().with_layout_motion(duration_ms, easing))
2326            },
2327        )
2328        .with_fn(
2329            "shared_layout",
2330            |node: &mut UiNode, group: ImmutableString, id: ImmutableString| {
2331                node.clone()
2332                    .with_shared_layout(group.to_string(), id.to_string())
2333            },
2334        )
2335        .with_fn("shared_layout", |node: &mut UiNode, id: ImmutableString| {
2336            node.clone().with_shared_layout_id(id.to_string())
2337        })
2338        .with_fn("disabled", |node: &mut UiNode, disabled: bool| {
2339            node.clone()
2340                .with_attribute("disabled", UiValue::Bool(disabled))
2341        })
2342        .with_fn(
2343            "env",
2344            |node: &mut UiNode, values: Map| -> Result<UiNode, Box<EvalAltResult>> {
2345                node.clone().with_environment(&values).map_err(|message| {
2346                    Box::new(EvalAltResult::ErrorRuntime(message.into(), Position::NONE))
2347                })
2348            },
2349        )
2350        .with_fn("tab_stop", |node: &mut UiNode, tab_stop: bool| {
2351            node.clone()
2352                .with_attribute("tab_stop", UiValue::Bool(tab_stop))
2353        })
2354        // The container's heading is drawn elsewhere (a host's panel header):
2355        // composition checks treat it like a container that starts with one.
2356        .with_fn("heading_elsewhere", |node: &mut UiNode| {
2357            node.clone()
2358                .with_attribute("heading_elsewhere", UiValue::Bool(true))
2359        })
2360        // A container that scrolls on one axis also takes wheel deltas of the other axis
2361        // on it: a horizontal tab strip scrolls with a vertical mouse wheel.
2362        .with_fn("translate_wheel", |node: &mut UiNode| {
2363            node.clone()
2364                .with_attribute("translate_wheel", UiValue::Bool(true))
2365        })
2366        // Pressing the node itself (not a focusable control inside it) moves
2367        // the window, and a double press runs the platform title-bar action.
2368        .with_fn("window_drag_area", |node: &mut UiNode| {
2369            node.clone()
2370                .with_attribute("window_drag_area", UiValue::Bool(true))
2371        })
2372        // A deliberate, greppable exception: this node opts out of the named
2373        // composition audit rules (for example a figure and its unit).
2374        .with_fn(
2375            "audit_allow",
2376            |node: &mut UiNode, rules: rhai::Array| -> Result<UiNode, Box<EvalAltResult>> {
2377                let mut ids = Vec::with_capacity(rules.len());
2378                for rule in rules {
2379                    let id = rule.into_string().map_err(|_| {
2380                        Box::new(EvalAltResult::ErrorRuntime(
2381                            "audit_allow expects an array of rule identifiers".into(),
2382                            Position::NONE,
2383                        ))
2384                    })?;
2385                    if crate::AuditRule::parse(&id).is_none() {
2386                        return Err(Box::new(EvalAltResult::ErrorRuntime(
2387                            format!("unknown audit rule `{id}`").into(),
2388                            Position::NONE,
2389                        )));
2390                    }
2391                    ids.push(UiValue::String(id));
2392                }
2393                Ok(node
2394                    .clone()
2395                    .with_attribute("audit_allow", UiValue::Array(ids)))
2396            },
2397        )
2398        .with_fn(
2399            "selectable",
2400            |node: &mut UiNode, selectable: bool| -> Result<UiNode, Box<EvalAltResult>> {
2401                if selectable && !matches!(node.kind(), UiNodeKind::Text { .. }) {
2402                    return Err(Box::new(EvalAltResult::ErrorRuntime(
2403                        "selectable(true) is supported only on text() nodes".into(),
2404                        Position::NONE,
2405                    )));
2406                }
2407                Ok(node
2408                    .clone()
2409                    .with_attribute("selectable", UiValue::Bool(selectable)))
2410            },
2411        )
2412        .with_fn(
2413            "tab_index",
2414            |node: &mut UiNode, index: INT| -> Result<UiNode, Box<EvalAltResult>> {
2415                if !(-32_768..=32_767).contains(&index) {
2416                    return Err(Box::new(EvalAltResult::ErrorRuntime(
2417                        "tab index must be between -32768 and 32767".into(),
2418                        Position::NONE,
2419                    )));
2420                }
2421                Ok(node
2422                    .clone()
2423                    .with_attribute("tab_index", UiValue::Integer(index)))
2424            },
2425        )
2426        .with_fn("tab_group", |node: &mut UiNode| {
2427            node.clone()
2428                .with_attribute("tab_group", UiValue::Bool(true))
2429        })
2430        .with_fn(
2431            "scrollbars",
2432            |node: &mut UiNode,
2433             horizontal: ImmutableString,
2434             vertical: ImmutableString|
2435             -> Result<UiNode, Box<EvalAltResult>> {
2436                let spec = crate::ScrollbarSpec::new(horizontal.as_str(), vertical.as_str())
2437                    .map_err(|error| {
2438                        Box::new(EvalAltResult::ErrorRuntime(error.into(), Position::NONE))
2439                    })?;
2440                Ok(node.clone().with_scrollbars(spec))
2441            },
2442        )
2443        .with_fn(
2444            "on_key_value",
2445            |call: NativeCallContext<'_>,
2446             node: &mut UiNode,
2447             key: ImmutableString,
2448             callback: FnPtr,
2449             payload: Dynamic|
2450             -> Result<UiNode, Box<EvalAltResult>> {
2451                let key = normalize_key_handler_name(key.as_str())?;
2452                let payload = UiValue::from_dynamic(payload).map_err(|error| {
2453                    Box::new(EvalAltResult::ErrorRuntime(
2454                        error.to_string().into(),
2455                        Position::NONE,
2456                    ))
2457                })?;
2458                Ok(node.clone().with_value_handler(
2459                    format!("key:{key}"),
2460                    retained_script_callback(&call, callback)?,
2461                    payload,
2462                ))
2463            },
2464        );
2465}
2466
2467fn register_value_event_methods(builder: &mut TypeBuilder<UiNode>) {
2468    builder
2469        .with_fn(
2470            "on_click_value",
2471            |call: NativeCallContext<'_>,
2472             node: &mut UiNode,
2473             callback: FnPtr,
2474             payload: Dynamic|
2475             -> Result<UiNode, Box<EvalAltResult>> {
2476                let payload = dynamic_ui_value(payload)?;
2477                Ok(node.clone().with_value_handler(
2478                    "click",
2479                    retained_script_callback(&call, callback)?,
2480                    payload,
2481                ))
2482            },
2483        )
2484        .with_fn(
2485            "on_hover_value",
2486            |call: NativeCallContext<'_>,
2487             node: &mut UiNode,
2488             callback: FnPtr,
2489             payload: Dynamic|
2490             -> Result<UiNode, Box<EvalAltResult>> {
2491                let payload = dynamic_ui_value(payload)?;
2492                Ok(node.clone().with_value_handler(
2493                    "hover_change",
2494                    retained_script_callback(&call, callback)?,
2495                    payload,
2496                ))
2497            },
2498        );
2499}
2500
2501fn register_raw_event_methods(builder: &mut TypeBuilder<UiNode>) {
2502    builder
2503        .with_fn(
2504            "on_click",
2505            |call: NativeCallContext<'_>,
2506             node: &mut UiNode,
2507             callback: FnPtr|
2508             -> Result<UiNode, Box<EvalAltResult>> {
2509                Ok(node
2510                    .clone()
2511                    .with_handler("click", retained_script_callback(&call, callback)?))
2512            },
2513        )
2514        .with_fn(
2515            "on",
2516            |call: NativeCallContext<'_>,
2517             node: &mut UiNode,
2518             event: ImmutableString,
2519             callback: FnPtr|
2520             -> Result<UiNode, Box<EvalAltResult>> {
2521                let event = normalize_node_event_name(event.as_str())?;
2522                Ok(node
2523                    .clone()
2524                    .with_handler(event, retained_script_callback(&call, callback)?))
2525            },
2526        )
2527        .with_fn(
2528            "on_capture",
2529            |call: NativeCallContext<'_>,
2530             node: &mut UiNode,
2531             event: ImmutableString,
2532             callback: FnPtr|
2533             -> Result<UiNode, Box<EvalAltResult>> {
2534                let event = normalize_node_event_name(event.as_str())?;
2535                Ok(node.clone().with_handler_phase(
2536                    event,
2537                    crate::EventPhase::Capture,
2538                    retained_script_callback(&call, callback)?,
2539                ))
2540            },
2541        )
2542        .with_fn(
2543            "on_bubble",
2544            |call: NativeCallContext<'_>,
2545             node: &mut UiNode,
2546             event: ImmutableString,
2547             callback: FnPtr|
2548             -> Result<UiNode, Box<EvalAltResult>> {
2549                let event = normalize_node_event_name(event.as_str())?;
2550                Ok(node.clone().with_handler_phase(
2551                    event,
2552                    crate::EventPhase::Bubble,
2553                    retained_script_callback(&call, callback)?,
2554                ))
2555            },
2556        );
2557    register_native_event_methods(builder);
2558}
2559
2560fn register_native_event_methods(builder: &mut TypeBuilder<UiNode>) {
2561    builder
2562        .with_fn(
2563            "on_click",
2564            |node: &mut UiNode,
2565             handler: crate::NativeHandlerRef|
2566             -> Result<UiNode, Box<EvalAltResult>> {
2567                handler.validate_event("click").map_err(|error| {
2568                    Box::new(EvalAltResult::ErrorRuntime(
2569                        error.to_string().into(),
2570                        Position::NONE,
2571                    ))
2572                })?;
2573                Ok(node.clone().with_handler("click", handler))
2574            },
2575        )
2576        .with_fn(
2577            "on_click_value",
2578            |node: &mut UiNode,
2579             handler: crate::NativeHandlerRef,
2580             payload: Dynamic|
2581             -> Result<UiNode, Box<EvalAltResult>> {
2582                handler.validate_event("click").map_err(|error| {
2583                    Box::new(EvalAltResult::ErrorRuntime(
2584                        error.to_string().into(),
2585                        Position::NONE,
2586                    ))
2587                })?;
2588                Ok(node
2589                    .clone()
2590                    .with_value_handler("click", handler, dynamic_ui_value(payload)?))
2591            },
2592        )
2593        .with_fn(
2594            "on",
2595            |node: &mut UiNode,
2596             event: ImmutableString,
2597             handler: crate::NativeHandlerRef|
2598             -> Result<UiNode, Box<EvalAltResult>> {
2599                let event = normalize_node_event_name(event.as_str())?;
2600                handler.validate_event(event.as_str()).map_err(|error| {
2601                    Box::new(EvalAltResult::ErrorRuntime(
2602                        error.to_string().into(),
2603                        Position::NONE,
2604                    ))
2605                })?;
2606                Ok(node.clone().with_handler(event, handler))
2607            },
2608        )
2609        .with_fn(
2610            "on_capture",
2611            |node: &mut UiNode,
2612             event: ImmutableString,
2613             handler: crate::NativeHandlerRef|
2614             -> Result<UiNode, Box<EvalAltResult>> {
2615                let event = normalize_node_event_name(event.as_str())?;
2616                handler.validate_event(event.as_str()).map_err(|error| {
2617                    Box::new(EvalAltResult::ErrorRuntime(
2618                        error.to_string().into(),
2619                        Position::NONE,
2620                    ))
2621                })?;
2622                Ok(node
2623                    .clone()
2624                    .with_handler_phase(event, crate::EventPhase::Capture, handler))
2625            },
2626        )
2627        .with_fn(
2628            "on_bubble",
2629            |node: &mut UiNode,
2630             event: ImmutableString,
2631             handler: crate::NativeHandlerRef|
2632             -> Result<UiNode, Box<EvalAltResult>> {
2633                let event = normalize_node_event_name(event.as_str())?;
2634                handler.validate_event(event.as_str()).map_err(|error| {
2635                    Box::new(EvalAltResult::ErrorRuntime(
2636                        error.to_string().into(),
2637                        Position::NONE,
2638                    ))
2639                })?;
2640                Ok(node
2641                    .clone()
2642                    .with_handler_phase(event, crate::EventPhase::Bubble, handler))
2643            },
2644        );
2645}
2646
2647fn normalize_node_event_name(event: &str) -> Result<String, Box<EvalAltResult>> {
2648    if let Some(key) = event.strip_prefix("key:") {
2649        return normalize_key_handler_name(key).map(|key| format!("key:{key}"));
2650    }
2651    // Keep the ordinary event namespace contract independent of raw keys.
2652    if (1..=64).contains(&event.len())
2653        && event.chars().all(|character| {
2654            character.is_ascii_lowercase()
2655                || character.is_ascii_digit()
2656                || matches!(character, '_' | ':')
2657        })
2658    {
2659        Ok(event.to_owned())
2660    } else {
2661        Err(Box::new(EvalAltResult::ErrorRuntime(
2662            format!("event `{event}` must be a 1-64 character snake_case or namespaced name")
2663                .into(),
2664            Position::NONE,
2665        )))
2666    }
2667}
2668
2669/// One grammar and normalization for every Rhai node-key registration entry.
2670/// The `key:` prefix consumes four bytes of the 64-byte event-name budget.
2671fn normalize_key_handler_name(key: &str) -> Result<String, Box<EvalAltResult>> {
2672    let invalid = || {
2673        Box::new(EvalAltResult::ErrorRuntime(
2674            "key handler name must be 1-60 ASCII letters, digits or `_`, or one ASCII punctuation character other than `:`, optionally preceded by distinct `ctrl+`, `alt+`, `shift+` or `cmd+` modifiers; whitespace and multi-key chords are not accepted"
2675                .into(),
2676            Position::NONE,
2677        ))
2678    };
2679    if key.len() > 60 {
2680        return Err(invalid());
2681    }
2682    // A lone `+` is the plus key, not a modifier separator.
2683    let (modifiers, base) = match key.rsplit_once('+') {
2684        Some((modifiers, base)) if !base.is_empty() => (Some(modifiers), base),
2685        _ => (None, key),
2686    };
2687    let named = !base.is_empty()
2688        && base
2689            .bytes()
2690            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
2691    let punctuation =
2692        base.len() == 1 && base.as_bytes()[0].is_ascii_punctuation() && base.as_bytes()[0] != b':';
2693    if !(named || punctuation) {
2694        return Err(invalid());
2695    }
2696    let mut present = [false; 4];
2697    if let Some(modifiers) = modifiers {
2698        for modifier in modifiers.split('+') {
2699            let index = KEY_MODIFIERS
2700                .iter()
2701                .position(|name| modifier.eq_ignore_ascii_case(name))
2702                .ok_or_else(invalid)?;
2703            if present[index] {
2704                return Err(invalid());
2705            }
2706            present[index] = true;
2707        }
2708    }
2709    Ok(canonical_key_name(present, &base.to_ascii_lowercase()))
2710}
2711
2712/// Modifier names in canonical order for key handler names.
2713pub(crate) const KEY_MODIFIERS: [&str; 4] = ["ctrl", "alt", "shift", "cmd"];
2714
2715/// `ctrl+alt+shift+cmd+key`, listing only the present modifiers.
2716pub(crate) fn canonical_key_name(present: [bool; 4], key: &str) -> String {
2717    let mut name = String::new();
2718    for (modifier, on) in KEY_MODIFIERS.iter().zip(present) {
2719        if on {
2720            name.push_str(modifier);
2721            name.push('+');
2722        }
2723    }
2724    name.push_str(key);
2725    name
2726}
2727
2728fn register_semantic_event_methods(builder: &mut TypeBuilder<UiNode>) {
2729    for (method, event) in [
2730        ("on_hover_change", "hover_change"),
2731        ("on_open_change", "open_change"),
2732        ("on_change", "change"),
2733    ] {
2734        let event = event.to_owned();
2735        builder.with_fn(
2736            method,
2737            move |call: NativeCallContext<'_>,
2738                  node: &mut UiNode,
2739                  callback: FnPtr|
2740                  -> Result<UiNode, Box<EvalAltResult>> {
2741                Ok(node
2742                    .clone()
2743                    .with_handler(event.clone(), retained_script_callback(&call, callback)?))
2744            },
2745        );
2746    }
2747}
2748
2749fn retained_script_callback(
2750    call: &NativeCallContext<'_>,
2751    callback: FnPtr,
2752) -> Result<ScriptCallback, Box<EvalAltResult>> {
2753    let mut callback = ScriptCallback::try_from_fn_ptr(callback, ScriptGeneration::default())
2754        .map_err(|error| {
2755            Box::new(EvalAltResult::ErrorRuntime(
2756                error.to_string().into(),
2757                Position::NONE,
2758            ))
2759        })?;
2760    callback.bind_native_context_if_unset(
2761        crate::invocation::ScriptInvocationContext::capture_retained(call),
2762    );
2763    Ok(callback)
2764}
2765
2766fn register_accessibility_methods(builder: &mut TypeBuilder<UiNode>) {
2767    register_accessibility_state_methods(builder);
2768    register_accessibility_reference_methods(builder);
2769}
2770
2771fn register_accessibility_state_methods(builder: &mut TypeBuilder<UiNode>) {
2772    builder
2773        .with_fn(
2774            "accessibility_role",
2775            |node: &mut UiNode, role: ImmutableString| {
2776                node.clone()
2777                    .with_attribute("role", UiValue::String(role.to_string()))
2778            },
2779        )
2780        .with_fn(
2781            "accessibility_label",
2782            |node: &mut UiNode, label: ImmutableString| {
2783                node.clone()
2784                    .with_attribute("label", UiValue::String(label.to_string()))
2785            },
2786        )
2787        .with_fn(
2788            "accessibility_checked",
2789            |node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
2790                let value = dynamic_ui_value(value)?;
2791                Ok(node.clone().with_attribute("checked", value))
2792            },
2793        )
2794        .with_fn(
2795            "accessibility_pressed",
2796            |node: &mut UiNode, pressed: bool| {
2797                node.clone()
2798                    .with_attribute("pressed", UiValue::Bool(pressed))
2799            },
2800        )
2801        .with_fn(
2802            "accessibility_selected",
2803            |node: &mut UiNode, selected: bool| {
2804                node.clone()
2805                    .with_attribute("selected", UiValue::Bool(selected))
2806            },
2807        )
2808        .with_fn(
2809            "accessibility_expanded",
2810            |node: &mut UiNode, expanded: bool| {
2811                node.clone()
2812                    .with_attribute("expanded", UiValue::Bool(expanded))
2813            },
2814        )
2815        .with_fn(
2816            "accessibility_orientation",
2817            |node: &mut UiNode,
2818             orientation: ImmutableString|
2819             -> Result<UiNode, Box<EvalAltResult>> {
2820                if !matches!(orientation.as_str(), "horizontal" | "vertical") {
2821                    return Err(Box::new(EvalAltResult::ErrorRuntime(
2822                        "accessibility_orientation must be horizontal or vertical".into(),
2823                        Position::NONE,
2824                    )));
2825                }
2826                Ok(node
2827                    .clone()
2828                    .with_attribute("orientation", UiValue::String(orientation.to_string())))
2829            },
2830        );
2831    register_accessibility_value_methods(builder);
2832    register_accessibility_collection_methods(builder);
2833}
2834
2835fn register_accessibility_value_methods(builder: &mut TypeBuilder<UiNode>) {
2836    register_accessibility_range_methods(builder);
2837    builder
2838        .with_fn(
2839            "accessibility_value",
2840            |node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
2841                let value = dynamic_ui_value(value)?;
2842                Ok(node.clone().with_attribute("value", value))
2843            },
2844        )
2845        .with_fn(
2846            "accessibility_placeholder",
2847            |node: &mut UiNode, placeholder: ImmutableString| {
2848                node.clone()
2849                    .with_attribute("placeholder", UiValue::String(placeholder.to_string()))
2850            },
2851        )
2852        .with_fn(
2853            "accessibility_key_shortcuts",
2854            |node: &mut UiNode, shortcuts: ImmutableString| {
2855                node.clone()
2856                    .with_attribute("key_shortcuts", UiValue::String(shortcuts.to_string()))
2857            },
2858        )
2859        .with_fn(
2860            "accessibility_current",
2861            |node: &mut UiNode, value: ImmutableString| -> Result<UiNode, Box<EvalAltResult>> {
2862                if !matches!(
2863                    value.as_str(),
2864                    "page" | "step" | "location" | "date" | "time" | "true"
2865                ) {
2866                    return Err(Box::new(EvalAltResult::ErrorRuntime(
2867                        "accessibility_current must be page, step, location, date, time, or true"
2868                            .into(),
2869                        Position::NONE,
2870                    )));
2871                }
2872                Ok(node
2873                    .clone()
2874                    .with_attribute("current", UiValue::String(value.to_string())))
2875            },
2876        )
2877        .with_fn(
2878            "accessibility_invalid",
2879            |node: &mut UiNode, invalid: bool| {
2880                node.clone()
2881                    .with_attribute("invalid", UiValue::Bool(invalid))
2882            },
2883        )
2884        .with_fn(
2885            "accessibility_read_only",
2886            |node: &mut UiNode, read_only: bool| {
2887                node.clone()
2888                    .with_attribute("read_only", UiValue::Bool(read_only))
2889            },
2890        );
2891}
2892
2893fn register_accessibility_collection_methods(builder: &mut TypeBuilder<UiNode>) {
2894    builder
2895        .with_fn(
2896            "accessibility_option",
2897            |node: &mut UiNode,
2898             label: ImmutableString,
2899             selected: bool,
2900             position: INT,
2901             size: INT|
2902             -> Result<UiNode, Box<EvalAltResult>> {
2903                let position = accessibility_positive_index(position, "option position")?;
2904                let size = accessibility_positive_index(size, "option set size")?;
2905                Ok(node
2906                    .clone()
2907                    .with_attribute("role", UiValue::String("option".to_owned()))
2908                    .with_attribute("label", UiValue::String(label.to_string()))
2909                    .with_attribute("selected", UiValue::Bool(selected))
2910                    .with_attribute("position_in_set", UiValue::Integer(position))
2911                    .with_attribute("size_of_set", UiValue::Integer(size)))
2912            },
2913        )
2914        .with_fn(
2915            "accessibility_table_cell",
2916            |node: &mut UiNode,
2917             label: ImmutableString,
2918             column: INT|
2919             -> Result<UiNode, Box<EvalAltResult>> {
2920                let column = accessibility_positive_index(column, "table column index")?;
2921                Ok(node
2922                    .clone()
2923                    .with_attribute("role", UiValue::String("gridcell".to_owned()))
2924                    .with_attribute("label", UiValue::String(label.to_string()))
2925                    .with_attribute("column_index", UiValue::Integer(column)))
2926            },
2927        )
2928        .with_fn(
2929            "accessibility_column_header",
2930            |node: &mut UiNode,
2931             label: ImmutableString,
2932             column: INT|
2933             -> Result<UiNode, Box<EvalAltResult>> {
2934                let column = accessibility_positive_index(column, "table column index")?;
2935                Ok(node
2936                    .clone()
2937                    .with_attribute("role", UiValue::String("columnheader".to_owned()))
2938                    .with_attribute("label", UiValue::String(label.to_string()))
2939                    .with_attribute("column_index", UiValue::Integer(column)))
2940            },
2941        )
2942        .with_fn(
2943            "accessibility_table_row",
2944            |node: &mut UiNode,
2945             label: ImmutableString,
2946             selected: bool,
2947             row: INT,
2948             position: INT,
2949             size: INT|
2950             -> Result<UiNode, Box<EvalAltResult>> {
2951                let row = accessibility_positive_index(row, "table row index")?;
2952                let position = accessibility_positive_index(position, "row position")?;
2953                let size = accessibility_positive_index(size, "row set size")?;
2954                Ok(node
2955                    .clone()
2956                    .with_attribute("role", UiValue::String("row".to_owned()))
2957                    .with_attribute("label", UiValue::String(label.to_string()))
2958                    .with_attribute("selected", UiValue::Bool(selected))
2959                    .with_attribute("row_index", UiValue::Integer(row))
2960                    .with_attribute("position_in_set", UiValue::Integer(position))
2961                    .with_attribute("size_of_set", UiValue::Integer(size)))
2962            },
2963        );
2964    for (method, attribute) in [
2965        ("accessibility_level", "level"),
2966        ("accessibility_position_in_set", "position_in_set"),
2967        ("accessibility_size_of_set", "size_of_set"),
2968        ("accessibility_row_index", "row_index"),
2969        ("accessibility_column_index", "column_index"),
2970        ("accessibility_row_count", "row_count"),
2971        ("accessibility_column_count", "column_count"),
2972    ] {
2973        let attribute = attribute.to_owned();
2974        builder.with_fn(
2975            method,
2976            move |node: &mut UiNode, value: INT| -> Result<UiNode, Box<EvalAltResult>> {
2977                if value < 1 {
2978                    return Err(Box::new(EvalAltResult::ErrorRuntime(
2979                        format!("{attribute} must be at least 1").into(),
2980                        Position::NONE,
2981                    )));
2982                }
2983                Ok(node
2984                    .clone()
2985                    .with_attribute(attribute.clone(), UiValue::Integer(value)))
2986            },
2987        );
2988    }
2989}
2990
2991fn accessibility_positive_index(value: INT, name: &str) -> Result<INT, Box<EvalAltResult>> {
2992    if value < 1 {
2993        Err(Box::new(EvalAltResult::ErrorRuntime(
2994            format!("accessibility {name} must be at least 1").into(),
2995            Position::NONE,
2996        )))
2997    } else {
2998        Ok(value)
2999    }
3000}
3001
3002fn register_accessibility_range_methods(builder: &mut TypeBuilder<UiNode>) {
3003    builder
3004        .with_fn(
3005            "accessibility_value_min",
3006            |node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
3007                let value = accessibility_number(value, "minimum")?;
3008                Ok(node
3009                    .clone()
3010                    .with_attribute("value_min", UiValue::Float(value)))
3011            },
3012        )
3013        .with_fn(
3014            "accessibility_value_max",
3015            |node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
3016                let value = accessibility_number(value, "maximum")?;
3017                Ok(node
3018                    .clone()
3019                    .with_attribute("value_max", UiValue::Float(value)))
3020            },
3021        );
3022}
3023
3024fn accessibility_number(value: Dynamic, name: &str) -> Result<f64, Box<EvalAltResult>> {
3025    let value = if value.is::<FLOAT>() {
3026        value.cast::<FLOAT>()
3027    } else if value.is::<INT>() {
3028        value
3029            .cast::<INT>()
3030            .to_string()
3031            .parse::<f64>()
3032            .map_err(|_| {
3033                Box::new(EvalAltResult::ErrorRuntime(
3034                    format!("accessibility {name} value cannot be represented").into(),
3035                    Position::NONE,
3036                ))
3037            })?
3038    } else {
3039        return Err(Box::new(EvalAltResult::ErrorRuntime(
3040            format!("accessibility {name} value must be a number").into(),
3041            Position::NONE,
3042        )));
3043    };
3044    if !value.is_finite() {
3045        return Err(Box::new(EvalAltResult::ErrorRuntime(
3046            format!("accessibility {name} value must be finite").into(),
3047            Position::NONE,
3048        )));
3049    }
3050    Ok(value)
3051}
3052
3053fn register_accessibility_reference_methods(builder: &mut TypeBuilder<UiNode>) {
3054    builder
3055        .with_fn(
3056            "accessibility_id",
3057            |node: &mut UiNode, id: ImmutableString| {
3058                node.clone()
3059                    .with_attribute("semantic_id", UiValue::String(id.to_string()))
3060            },
3061        )
3062        .with_fn(
3063            "test_id",
3064            |node: &mut UiNode, id: ImmutableString| -> Result<UiNode, Box<EvalAltResult>> {
3065                let id = id.to_string();
3066                if id.is_empty()
3067                    || id.len() > 128
3068                    || !id.chars().all(|character| {
3069                        character.is_ascii_alphanumeric()
3070                            || matches!(character, '_' | '-' | '.' | ':')
3071                    })
3072                {
3073                    return Err(Box::new(EvalAltResult::ErrorRuntime(
3074                        "test_id must be 1-128 safe identifier characters".into(),
3075                        Position::NONE,
3076                    )));
3077                }
3078                Ok(node.clone().with_attribute("test_id", UiValue::String(id)))
3079            },
3080        )
3081        .with_fn(
3082            "accessibility_labelled_by",
3083            |node: &mut UiNode, id: ImmutableString| {
3084                node.clone()
3085                    .with_attribute("labelled_by", UiValue::String(id.to_string()))
3086            },
3087        )
3088        .with_fn(
3089            "accessibility_described_by",
3090            |node: &mut UiNode, ids: ImmutableString| {
3091                node.clone()
3092                    .with_attribute("described_by", UiValue::String(ids.to_string()))
3093            },
3094        )
3095        .with_fn(
3096            "accessibility_required",
3097            |node: &mut UiNode, required: bool| {
3098                node.clone()
3099                    .with_attribute("required", UiValue::Bool(required))
3100            },
3101        );
3102}
3103
3104fn dynamic_ui_value(value: Dynamic) -> Result<UiValue, Box<EvalAltResult>> {
3105    UiValue::from_dynamic(value).map_err(|error| {
3106        Box::new(EvalAltResult::ErrorRuntime(
3107            error.to_string().into(),
3108            Position::NONE,
3109        ))
3110    })
3111}
3112
3113pub(crate) fn text_node(call: NativeCallContext<'_>, text: ImmutableString) -> UiNode {
3114    with_call_source(UiNode::text(text), call)
3115}
3116
3117pub(crate) fn span_value(text: ImmutableString) -> Span {
3118    Span::new(text)
3119}
3120
3121pub(crate) fn rich_text_node(
3122    call: NativeCallContext<'_>,
3123    spans: Array,
3124) -> Result<UiNode, Box<EvalAltResult>> {
3125    let spans = spans
3126        .into_iter()
3127        .enumerate()
3128        .map(|(index, value)| {
3129            value.try_cast::<Span>().ok_or_else(|| {
3130                Box::new(EvalAltResult::ErrorRuntime(
3131                    format!("text span {index} must be Span").into(),
3132                    Position::NONE,
3133                ))
3134            })
3135        })
3136        .collect::<Result<Vec<_>, _>>()?;
3137    let text = spans.iter().map(Span::text).collect::<String>();
3138    let mut boundaries = text
3139        .grapheme_indices(true)
3140        .map(|(index, _)| index)
3141        .collect::<BTreeSet<_>>();
3142    boundaries.insert(text.len());
3143    let mut offset = 0usize;
3144    for (index, span) in spans.iter().enumerate() {
3145        offset = offset.saturating_add(span.text().len());
3146        if !boundaries.contains(&offset) {
3147            return Err(Box::new(EvalAltResult::ErrorRuntime(
3148                format!("text span {index} splits an extended grapheme cluster").into(),
3149                Position::NONE,
3150            )));
3151        }
3152    }
3153    Ok(with_call_source(UiNode::rich_text(spans), call))
3154}
3155
3156pub(crate) fn canvas_node(call: NativeCallContext<'_>, scene: crate::CanvasScene) -> UiNode {
3157    with_call_source(UiNode::canvas(scene), call)
3158}
3159
3160pub(crate) fn svg_node(
3161    call: NativeCallContext<'_>,
3162    source: &str,
3163) -> Result<UiNode, Box<EvalAltResult>> {
3164    UiNode::svg(source)
3165        .map(|node| with_call_source(node, call))
3166        .map_err(|error| {
3167            Box::new(EvalAltResult::ErrorRuntime(
3168                error.to_string().into(),
3169                Position::NONE,
3170            ))
3171        })
3172}
3173
3174pub(crate) fn box_node(
3175    call: NativeCallContext<'_>,
3176    children: Array,
3177) -> Result<UiNode, Box<rhai::EvalAltResult>> {
3178    collect_children(children, "box")
3179        .map(|children| with_call_source(UiNode::box_node(children), call))
3180}
3181
3182pub(crate) fn fragment_node(
3183    call: NativeCallContext<'_>,
3184    children: Array,
3185) -> Result<UiNode, Box<rhai::EvalAltResult>> {
3186    collect_children(children, "fragment")
3187        .map(|children| with_call_source(UiNode::fragment(children), call))
3188}
3189
3190#[allow(clippy::needless_pass_by_value)]
3191pub(crate) fn motion_group_node(
3192    call: NativeCallContext<'_>,
3193    id: ImmutableString,
3194    children: Array,
3195) -> Result<UiNode, Box<EvalAltResult>> {
3196    let id = id.to_string();
3197    if id.is_empty()
3198        || id.len() > 128
3199        || !id
3200            .chars()
3201            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
3202    {
3203        return Err(Box::new(EvalAltResult::ErrorRuntime(
3204            "motion group id must be 1-128 safe ASCII characters".into(),
3205            Position::NONE,
3206        )));
3207    }
3208    let mut node = fragment_node(call, children)?;
3209    apply_motion_group(&mut node, &id, None);
3210    Ok(node)
3211}
3212
3213/// Give the nodes that declare only a shared-layout id this group. `scope` is
3214/// the declaring instance when known; otherwise the next component binding
3215/// supplies it.
3216pub(crate) fn apply_motion_group(node: &mut UiNode, group: &str, scope: Option<&str>) {
3217    if node.component_root.is_some() {
3218        node.apply_presentation_mutation(NodePresentationMutation::MotionGroup(
3219            group.to_owned(),
3220            scope.map(str::to_owned),
3221        ));
3222    } else {
3223        apply_motion_group_contents(node, group, scope);
3224    }
3225}
3226
3227fn apply_motion_group_contents(node: &mut UiNode, group: &str, scope: Option<&str>) {
3228    if node.attributes.contains_key("shared_layout_id")
3229        && !node.attributes.contains_key("shared_layout_group")
3230    {
3231        node.attributes.insert(
3232            "shared_layout_group".to_owned(),
3233            UiValue::String(group.to_owned()),
3234        );
3235        if let Some(scope) = scope {
3236            node.attributes.insert(
3237                "shared_layout_scope".to_owned(),
3238                UiValue::String(scope.to_owned()),
3239            );
3240        }
3241    }
3242    match &mut node.kind {
3243        UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
3244            for child in children {
3245                apply_motion_group(child, group, scope);
3246            }
3247        }
3248        UiNodeKind::Overlay {
3249            trigger, content, ..
3250        } => {
3251            apply_motion_group(trigger, group, scope);
3252            apply_motion_group(content, group, scope);
3253        }
3254        UiNodeKind::Layer { content, .. } => apply_motion_group(content, group, scope),
3255        UiNodeKind::ErrorBoundary { child, fallback } => {
3256            apply_motion_group(child, group, scope);
3257            apply_motion_group(fallback, group, scope);
3258        }
3259        UiNodeKind::VirtualCollection { spec } => {
3260            if spec.inherited_motion_group.is_none() {
3261                spec.inherited_motion_group = Some(group.to_owned());
3262                spec.inherited_motion_scope = scope.map(str::to_owned);
3263            }
3264            for child in spec.realized.values_mut() {
3265                apply_motion_group(child, group, scope);
3266            }
3267        }
3268        UiNodeKind::Text { .. }
3269        | UiNodeKind::RichText { .. }
3270        | UiNodeKind::Canvas { .. }
3271        | UiNodeKind::Svg { .. }
3272        | UiNodeKind::Custom { .. }
3273        | UiNodeKind::Image { .. }
3274        | UiNodeKind::DirectionalImage { .. } => {}
3275    }
3276}
3277
3278pub(crate) fn stack_node(
3279    call: NativeCallContext<'_>,
3280    children: Array,
3281) -> Result<UiNode, Box<rhai::EvalAltResult>> {
3282    collect_children(children, "stack").map(|children| {
3283        with_call_source(
3284            UiNode::box_node(children).with_style(&Style::new().relative()),
3285            call,
3286        )
3287    })
3288}
3289
3290pub(crate) fn column_node(
3291    call: NativeCallContext<'_>,
3292    children: Array,
3293) -> Result<UiNode, Box<rhai::EvalAltResult>> {
3294    collect_children(children, "column")
3295        .map(|children| with_call_source(UiNode::column(children), call))
3296}
3297
3298pub(crate) fn row_node(
3299    call: NativeCallContext<'_>,
3300    children: Array,
3301) -> Result<UiNode, Box<rhai::EvalAltResult>> {
3302    collect_children(children, "row").map(|children| with_call_source(UiNode::row(children), call))
3303}
3304
3305pub(crate) fn error_boundary_node(
3306    call: NativeCallContext<'_>,
3307    child: UiNode,
3308    fallback: UiNode,
3309) -> UiNode {
3310    with_call_source(UiNode::error_boundary(child, fallback), call)
3311}
3312
3313pub(crate) fn lazy_error_boundary_node(
3314    call: NativeCallContext<'_>,
3315    child: FnPtr,
3316    fallback: FnPtr,
3317) -> Result<UiNode, Box<EvalAltResult>> {
3318    let fallback_node = fallback.call_within_context::<UiNode>(&call, ())?;
3319    let child_result = child.call_within_context::<UiNode>(&call, ());
3320    drop(child);
3321    drop(fallback);
3322    match child_result {
3323        Ok(child) => Ok(with_call_source(
3324            UiNode::error_boundary(child, fallback_node),
3325            call,
3326        )),
3327        Err(error) => Ok(with_call_source(
3328            fallback_node.with_attribute("boundary_error", UiValue::String(error.to_string())),
3329            call,
3330        )),
3331    }
3332}
3333
3334pub(crate) fn image_node(
3335    call: NativeCallContext<'_>,
3336    handle: OpaqueHandle,
3337) -> Result<UiNode, Box<EvalAltResult>> {
3338    Ok(with_call_source(
3339        UiNode::image_source(image_handle_source(handle)?),
3340        call,
3341    ))
3342}
3343
3344pub(crate) fn asset_image_node(call: NativeCallContext<'_>, asset: AssetId) -> UiNode {
3345    with_call_source(UiNode::asset_image(asset), call)
3346}
3347
3348pub(crate) fn generic_image_node(
3349    call: NativeCallContext<'_>,
3350    source: Dynamic,
3351) -> Result<UiNode, Box<EvalAltResult>> {
3352    Ok(with_call_source(
3353        UiNode::image_source(parse_image_source(source)?),
3354        call,
3355    ))
3356}
3357
3358pub(crate) fn directional_image_node(
3359    call: NativeCallContext<'_>,
3360    left_to_right: OpaqueHandle,
3361    right_to_left: OpaqueHandle,
3362) -> Result<UiNode, Box<EvalAltResult>> {
3363    Ok(with_call_source(
3364        UiNode::directional_image_sources(
3365            image_handle_source(left_to_right)?,
3366            image_handle_source(right_to_left)?,
3367        ),
3368        call,
3369    ))
3370}
3371
3372pub(crate) fn directional_asset_image_node(
3373    call: NativeCallContext<'_>,
3374    left_to_right: AssetId,
3375    right_to_left: AssetId,
3376) -> UiNode {
3377    with_call_source(
3378        UiNode::directional_asset_image(left_to_right, right_to_left),
3379        call,
3380    )
3381}
3382
3383pub(crate) fn generic_directional_image_node(
3384    call: NativeCallContext<'_>,
3385    left_to_right: Dynamic,
3386    right_to_left: Dynamic,
3387) -> Result<UiNode, Box<EvalAltResult>> {
3388    Ok(with_call_source(
3389        UiNode::directional_image_sources(
3390            parse_image_source(left_to_right)?,
3391            parse_image_source(right_to_left)?,
3392        ),
3393        call,
3394    ))
3395}
3396
3397fn parse_image_source(source: Dynamic) -> Result<ImageSourceSpec, Box<EvalAltResult>> {
3398    if source.is::<AssetId>() {
3399        Ok(ImageSourceSpec::Asset(source.cast::<AssetId>()))
3400    } else if source.is::<OpaqueHandle>() {
3401        image_handle_source(source.cast::<OpaqueHandle>())
3402    } else {
3403        Err(Box::new(EvalAltResult::ErrorRuntime(
3404            format!(
3405                "image source must be an AssetId or image handle, got {}",
3406                source.type_name()
3407            )
3408            .into(),
3409            Position::NONE,
3410        )))
3411    }
3412}
3413
3414fn image_handle_source(handle: OpaqueHandle) -> Result<ImageSourceSpec, Box<EvalAltResult>> {
3415    if handle.kind() == "image" {
3416        Ok(ImageSourceSpec::Handle(handle))
3417    } else {
3418        Err(Box::new(EvalAltResult::ErrorRuntime(
3419            format!(
3420                "image source handle must have kind `image`, got `{}`",
3421                handle.kind()
3422            )
3423            .into(),
3424            Position::NONE,
3425        )))
3426    }
3427}
3428
3429pub(crate) fn overlay_node(
3430    call: NativeCallContext<'_>,
3431    trigger: UiNode,
3432    overlay_content: UiNode,
3433    mut config: Map,
3434) -> Result<UiNode, Box<EvalAltResult>> {
3435    let id = required_string(&mut config, "id")?;
3436    if id.trim().is_empty() {
3437        return overlay_config_error("overlay ID cannot be empty");
3438    }
3439    let parent = optional_string(&mut config, "parent")?;
3440    let kind = match optional_string(&mut config, "kind")?.as_deref() {
3441        None | Some("popover") => OverlayKind::Popover,
3442        Some("combobox") => OverlayKind::Combobox,
3443        Some("tooltip") => OverlayKind::Tooltip,
3444        Some("dialog") => OverlayKind::Dialog,
3445        Some("sheet") => OverlayKind::Sheet,
3446        Some("menu") => OverlayKind::Menu,
3447        Some(other) => return overlay_config_error(format!("unknown overlay kind `{other}`")),
3448    };
3449    let placement = match optional_string(&mut config, "placement")?.as_deref() {
3450        None | Some("bottom") => OverlayPlacement::Bottom,
3451        Some("top") => OverlayPlacement::Top,
3452        Some("left") => OverlayPlacement::Left,
3453        Some("right") => OverlayPlacement::Right,
3454        Some("start") => OverlayPlacement::Start,
3455        Some("end") => OverlayPlacement::End,
3456        Some("center") => OverlayPlacement::Center,
3457        Some(other) => {
3458            return overlay_config_error(format!("unknown overlay placement `{other}`"));
3459        }
3460    };
3461    let align = match optional_string(&mut config, "align")?.as_deref() {
3462        None | Some("center") => crate::OverlayAlign::Center,
3463        Some("start") => crate::OverlayAlign::Start,
3464        Some("end") => crate::OverlayAlign::End,
3465        Some(other) => {
3466            return overlay_config_error(format!(
3467                "unknown overlay align `{other}` (expected `start`, `center` or `end`)"
3468            ));
3469        }
3470    };
3471    let open = optional_bool(&mut config, "open")?.unwrap_or(false);
3472    let anchor = optional_overlay_bounds(&mut config, "anchor")?;
3473    let gap = optional_number(&mut config, "gap")?.unwrap_or(8.0);
3474    if !gap.is_finite() || gap < 0.0 {
3475        return overlay_config_error("overlay gap must be finite and non-negative");
3476    }
3477    let modal = optional_bool(&mut config, "modal")?
3478        .unwrap_or(matches!(kind, OverlayKind::Dialog | OverlayKind::Sheet));
3479    let activate_on_trigger = optional_bool(&mut config, "activate_on_trigger")?.unwrap_or(true);
3480    let width_policy = if optional_bool(&mut config, "match_trigger_width")?.unwrap_or(false) {
3481        OverlayWidthPolicy::MatchTrigger
3482    } else {
3483        OverlayWidthPolicy::Content
3484    };
3485    let initial_focus = match optional_string(&mut config, "initial_focus")?.as_deref() {
3486        None | Some("panel") => OverlayInitialFocus::Panel,
3487        Some("first") => OverlayInitialFocus::First,
3488        Some(other) => {
3489            return overlay_config_error(format!(
3490                "unknown overlay initial_focus `{other}` (expected `panel` or `first`)"
3491            ));
3492        }
3493    };
3494    let dismiss_on_escape = optional_bool(&mut config, "dismiss_on_escape")?.unwrap_or(true);
3495    let dismiss_on_outside = optional_bool(&mut config, "dismiss_on_outside")?.unwrap_or(true);
3496    let show_delay_ms = optional_usize(&mut config, "show_delay_ms")?;
3497    let hide_delay_ms = optional_usize(&mut config, "hide_delay_ms")?;
3498    let tooltip_delays = (kind == OverlayKind::Tooltip).then(|| TooltipDelays {
3499        show_ms: u64::try_from(show_delay_ms.unwrap_or(500)).unwrap_or(u64::MAX),
3500        hide_ms: u64::try_from(hide_delay_ms.unwrap_or(100)).unwrap_or(u64::MAX),
3501    });
3502    if let Some((unknown, _)) = config.into_iter().next() {
3503        return overlay_config_error(format!("unknown overlay config field `{unknown}`"));
3504    }
3505    Ok(with_call_source(
3506        UiNode::overlay(
3507            trigger,
3508            overlay_content,
3509            OverlayNodeSpec {
3510                id: OverlayId::new(id),
3511                owner: None,
3512                parent: parent.map(OverlayId::new),
3513                kind,
3514                placement,
3515                align,
3516                anchor,
3517                open,
3518                gap,
3519                modal,
3520                dismiss: OverlayDismissPolicy {
3521                    escape: dismiss_on_escape,
3522                    outside: dismiss_on_outside,
3523                },
3524                tooltip_delays,
3525                initial_focus,
3526                activate_on_trigger,
3527                width_policy,
3528            },
3529        ),
3530        call,
3531    ))
3532}
3533
3534pub(crate) fn layer_node(
3535    call: NativeCallContext<'_>,
3536    content: UiNode,
3537    mut config: Map,
3538) -> Result<UiNode, Box<EvalAltResult>> {
3539    let id = required_string(&mut config, "id")?;
3540    if id.trim().is_empty() {
3541        return overlay_config_error("layer ID cannot be empty");
3542    }
3543    let placement = match optional_string(&mut config, "placement")?.as_deref() {
3544        None | Some("top_right") => LayerPlacement::TopRight,
3545        Some("top_left") => LayerPlacement::TopLeft,
3546        Some("bottom_left") => LayerPlacement::BottomLeft,
3547        Some("bottom_right") => LayerPlacement::BottomRight,
3548        Some("center") => LayerPlacement::Center,
3549        Some("fill") => LayerPlacement::Fill,
3550        Some(other) => return overlay_config_error(format!("unknown layer placement `{other}`")),
3551    };
3552    let inset = optional_number(&mut config, "inset")?.unwrap_or(12.0);
3553    if !inset.is_finite() || inset < 0.0 {
3554        return overlay_config_error("layer inset must be finite and non-negative");
3555    }
3556    let priority = optional_usize(&mut config, "priority")?.unwrap_or(9_000);
3557    if priority > 1_000_000 {
3558        return overlay_config_error("layer priority cannot exceed 1000000");
3559    }
3560    if let Some((unknown, _)) = config.into_iter().next() {
3561        return overlay_config_error(format!("unknown layer config field `{unknown}`"));
3562    }
3563    Ok(with_call_source(
3564        UiNode::layer(
3565            content,
3566            LayerNodeSpec {
3567                id: OverlayId::new(id),
3568                owner: None,
3569                placement,
3570                inset,
3571                priority,
3572            },
3573        ),
3574        call,
3575    ))
3576}
3577
3578fn with_call_source(node: UiNode, call: NativeCallContext<'_>) -> UiNode {
3579    let position = call.call_position();
3580    let module = call
3581        .call_source()
3582        .or_else(|| call.fn_source())
3583        .map(ToOwned::to_owned);
3584    let _ = std::hint::black_box(call);
3585    let Some(module) = module else {
3586        return node;
3587    };
3588    let (Some(line), Some(column)) = (position.line(), position.position()) else {
3589        return node;
3590    };
3591    let (Ok(line), Ok(column)) = (u32::try_from(line), u32::try_from(column)) else {
3592        return node;
3593    };
3594    node.with_source(SourceLocation {
3595        module,
3596        line,
3597        column,
3598    })
3599}
3600
3601fn required_string(config: &mut Map, name: &str) -> Result<String, Box<EvalAltResult>> {
3602    optional_string(config, name)?.ok_or_else(|| {
3603        Box::new(EvalAltResult::ErrorRuntime(
3604            format!("overlay config field `{name}` is required").into(),
3605            Position::NONE,
3606        ))
3607    })
3608}
3609
3610fn optional_string(config: &mut Map, name: &str) -> Result<Option<String>, Box<EvalAltResult>> {
3611    let Some(value) = config.remove(name) else {
3612        return Ok(None);
3613    };
3614    value
3615        .try_cast::<ImmutableString>()
3616        .map(|value| Some(value.to_string()))
3617        .ok_or_else(|| Box::new(overlay_type_error(name, "string")))
3618}
3619
3620fn optional_bool(config: &mut Map, name: &str) -> Result<Option<bool>, Box<EvalAltResult>> {
3621    let Some(value) = config.remove(name) else {
3622        return Ok(None);
3623    };
3624    value
3625        .try_cast::<bool>()
3626        .map(Some)
3627        .ok_or_else(|| Box::new(overlay_type_error(name, "bool")))
3628}
3629
3630fn optional_number(config: &mut Map, name: &str) -> Result<Option<f64>, Box<EvalAltResult>> {
3631    let Some(value) = config.remove(name) else {
3632        return Ok(None);
3633    };
3634    if value.is::<INT>() {
3635        value
3636            .cast::<INT>()
3637            .to_string()
3638            .parse::<f64>()
3639            .map(Some)
3640            .map_err(|_| Box::new(overlay_type_error(name, "number")))
3641    } else if value.is::<FLOAT>() {
3642        Ok(Some(value.cast::<FLOAT>()))
3643    } else {
3644        Err(Box::new(overlay_type_error(name, "number")))
3645    }
3646}
3647
3648fn optional_usize(config: &mut Map, name: &str) -> Result<Option<usize>, Box<EvalAltResult>> {
3649    let Some(value) = config.remove(name) else {
3650        return Ok(None);
3651    };
3652    value
3653        .try_cast::<INT>()
3654        .and_then(|value| usize::try_from(value).ok())
3655        .map(Some)
3656        .ok_or_else(|| Box::new(overlay_type_error(name, "a non-negative integer")))
3657}
3658
3659fn optional_overlay_bounds(
3660    config: &mut Map,
3661    name: &str,
3662) -> Result<Option<crate::OverlayBounds>, Box<EvalAltResult>> {
3663    let Some(value) = config.remove(name) else {
3664        return Ok(None);
3665    };
3666    let Some(mut bounds) = value.try_cast::<Map>() else {
3667        return Err(Box::new(overlay_type_error(
3668            name,
3669            "a map with x, y, width, and height",
3670        )));
3671    };
3672    let x = optional_number(&mut bounds, "x")?
3673        .ok_or_else(|| Box::new(overlay_type_error("anchor.x", "number")))?;
3674    let y = optional_number(&mut bounds, "y")?
3675        .ok_or_else(|| Box::new(overlay_type_error("anchor.y", "number")))?;
3676    let width = optional_number(&mut bounds, "width")?.unwrap_or(0.0);
3677    let height = optional_number(&mut bounds, "height")?.unwrap_or(0.0);
3678    if let Some((unknown, _)) = bounds.into_iter().next() {
3679        return overlay_config_error(format!("unknown overlay anchor field `{unknown}`"));
3680    }
3681    if !x.is_finite()
3682        || !y.is_finite()
3683        || !width.is_finite()
3684        || width < 0.0
3685        || !height.is_finite()
3686        || height < 0.0
3687    {
3688        return overlay_config_error("overlay anchor geometry must be finite and non-negative");
3689    }
3690    Ok(Some(crate::OverlayBounds {
3691        x,
3692        y,
3693        width,
3694        height,
3695    }))
3696}
3697
3698fn overlay_type_error(name: &str, expected: &str) -> EvalAltResult {
3699    EvalAltResult::ErrorRuntime(
3700        format!("overlay config field `{name}` must be {expected}").into(),
3701        Position::NONE,
3702    )
3703}
3704
3705fn overlay_config_error<T>(message: impl Into<String>) -> Result<T, Box<EvalAltResult>> {
3706    Err(Box::new(EvalAltResult::ErrorRuntime(
3707        message.into().into(),
3708        Position::NONE,
3709    )))
3710}
3711
3712fn collect_children(
3713    children: Array,
3714    constructor: &str,
3715) -> Result<Vec<UiNode>, Box<rhai::EvalAltResult>> {
3716    let mut nodes = Vec::with_capacity(children.len());
3717    for (index, child) in children.into_iter().enumerate() {
3718        match child.try_cast::<UiNode>() {
3719            Some(node) => nodes.push(node),
3720            None => {
3721                return Err(format!("{constructor} child at index {index} is not a UiNode").into());
3722            }
3723        }
3724    }
3725    Ok(nodes)
3726}
3727
3728#[cfg(test)]
3729mod tests {
3730    use super::*;
3731    use crate::{
3732        ColorValue, EventPropagation, Length, MotionProperty, MotionTransition, Rgba8, SignalId,
3733        SignalKind, SignalProperty,
3734    };
3735
3736    #[test]
3737    fn node_styles_share_immutable_snapshots_and_mutate_without_aliasing() {
3738        let empty = UiNode::text("empty");
3739        assert!(Rc::ptr_eq(&empty.style, &UiNode::text("other").style));
3740        let original = empty.with_style(
3741            &Style::new()
3742                .opacity(0.25)
3743                .unwrap()
3744                .inset_start(crate::SignedLength::pixels(-4.0).unwrap()),
3745        );
3746        let shared = original.clone();
3747        assert!(Rc::ptr_eq(&original.style, &shared.style));
3748        let changed = shared.with_style(
3749            &Style::new()
3750                .opacity(0.75)
3751                .unwrap()
3752                .inset_end(Length::Pixels(2.0)),
3753        );
3754        assert_eq!(original.style().base.opacity, Some(0.25));
3755        assert!(original.style().base.inset_end.is_none());
3756        assert_eq!(changed.style().base.opacity, Some(0.75));
3757        let ghost = changed.motion_ghost().unwrap();
3758        assert!(ghost.style().base.inset_start.is_none());
3759        assert!(ghost.style().base.inset_end.is_none());
3760        assert!(changed.style().base.inset_start.is_some());
3761        assert!(changed.style().base.inset_end.is_some());
3762        println!(
3763            "UiNode={} bytes; Style={} bytes",
3764            std::mem::size_of::<UiNode>(),
3765            std::mem::size_of::<Style>()
3766        );
3767        assert!(
3768            std::mem::size_of::<UiNode>() <= 2048,
3769            "node stack values must not inline a multi-kilobyte Style"
3770        );
3771    }
3772
3773    #[test]
3774    fn node_identity_style_source_and_attributes_are_runtime_owned() {
3775        let node = UiNode::text("hello")
3776            .with_key("greeting")
3777            .with_style(
3778                &Style::new()
3779                    .width(Length::pixels(100.0).unwrap())
3780                    .text_color(ColorValue::Literal(Rgba8::from_rgb_hex(0x00ff_ffff))),
3781            )
3782            .with_source(SourceLocation {
3783                module: "ui/main.rhai".to_owned(),
3784                line: 4,
3785                column: 9,
3786            })
3787            .with_attribute("role", UiValue::String("label".to_owned()));
3788
3789        assert_eq!(node.key().map(NodeKey::as_str), Some("greeting"));
3790        assert_eq!(node.source().unwrap().line, 4);
3791        assert_eq!(
3792            node.attributes().get("role"),
3793            Some(&UiValue::String("label".to_owned()))
3794        );
3795    }
3796
3797    #[test]
3798    fn image_functions_reject_handles_of_another_kind_when_called() {
3799        let runtime = crate::RuntimeEngine::new();
3800        let mut scope = rhai::Scope::new();
3801        scope.push("task", OpaqueHandle::new("task", 1));
3802        scope.push("picture", OpaqueHandle::new("image", 2));
3803        for script in [
3804            "image(task)",
3805            "directional_image(task, picture)",
3806            "directional_image(picture, task)",
3807            "image_source(task)",
3808            "directional_image_source(picture, task)",
3809        ] {
3810            let error = runtime
3811                .engine()
3812                .eval_with_scope::<UiNode>(&mut scope, script)
3813                .unwrap_err();
3814            assert!(
3815                error
3816                    .to_string()
3817                    .contains("image source handle must have kind `image`, got `task`"),
3818                "{script}: {error}"
3819            );
3820        }
3821        for script in ["image(picture)", "directional_image(picture, picture)"] {
3822            runtime
3823                .engine()
3824                .eval_with_scope::<UiNode>(&mut scope, script)
3825                .unwrap_or_else(|error| panic!("{script}: {error}"));
3826        }
3827    }
3828
3829    #[test]
3830    fn selectable_rejects_non_text_nodes_at_the_script_boundary() {
3831        let mut runtime = crate::RuntimeEngine::new();
3832        let compiled = runtime
3833            .compile_named("selectable.rhai", "fn view() { row([]).selectable(true) }")
3834            .unwrap();
3835        let error = runtime.render(&compiled).unwrap_err();
3836        assert!(
3837            error
3838                .to_string()
3839                .contains("selectable(true) is supported only on text() nodes"),
3840            "{error}"
3841        );
3842    }
3843
3844    #[test]
3845    fn component_replacement_preserves_every_external_presentation_mutation() {
3846        let component = ComponentInstancePath::root("Probe", "owned");
3847        let signal = crate::NativeSignal::new(
3848            SignalId::new(component.clone(), "opacity", SignalKind::Float).unwrap(),
3849        );
3850        let reference =
3851            crate::ElementRef::new(crate::ElementRefId::new(component.clone(), "target").unwrap());
3852        let style = Style::new().width(Length::pixels(120.0).unwrap());
3853        let part_style = Style::new().height(Length::pixels(24.0).unwrap());
3854        let animation = MotionSource::Transition(MotionTransition::new(
3855            MotionProperty::Opacity,
3856            0.0,
3857            1.0,
3858            100,
3859        ));
3860        let mut presented = UiNode::text("old")
3861            .with_key("owned")
3862            .with_component_root(component.clone())
3863            .with_key("presented")
3864            .with_style(&style)
3865            .with_part_style("label", part_style.clone())
3866            .with_signal_binding(SignalProperty::Opacity, signal.clone())
3867            .unwrap()
3868            .with_element_ref(reference.clone())
3869            .with_attribute("role", UiValue::String("status".to_owned()))
3870            .with_host_handler(
3871                "click",
3872                crate::HostCallback::new("outer.click", |_, _, _| EventPropagation::Handled),
3873            )
3874            .with_handler_payload("click", UiValue::Integer(7))
3875            .with_motion(animation.clone());
3876        assert_eq!(presented.presentation.len(), 9);
3877
3878        let replacement = UiNode::text("new")
3879            .with_key("replacement")
3880            .with_component_root(component.clone());
3881        assert!(presented.replace_component_subtree(&component, replacement));
3882        assert!(matches!(presented.kind(), UiNodeKind::Text { text } if text == "new"));
3883        assert_eq!(presented.key().map(NodeKey::as_str), Some("presented"));
3884        assert_eq!(presented.style(), &style);
3885        assert_eq!(presented.part_style("label"), Some(&part_style));
3886        assert_eq!(
3887            presented.signal_bindings().collect::<Vec<_>>(),
3888            vec![(SignalProperty::Opacity, &signal)]
3889        );
3890        assert_eq!(presented.element_ref(), Some(&reference));
3891        assert_eq!(
3892            presented.attributes().get("role"),
3893            Some(&UiValue::String("status".to_owned()))
3894        );
3895        assert_eq!(presented.event_handlers("click").len(), 1);
3896        assert_eq!(
3897            presented.handler_payload("click"),
3898            Some(&UiValue::Integer(7))
3899        );
3900        assert_eq!(presented.motions(), &[animation]);
3901        assert_eq!(presented.presentation.len(), 9);
3902    }
3903
3904    #[test]
3905    fn component_snapshot_activates_lazily_and_stays_free_of_outer_presentation() {
3906        let component = ComponentInstancePath::root("Probe", "lazy");
3907        let snapshot = ComponentOwnedSnapshot::default();
3908        let node = UiNode::text("initial")
3909            .with_component_root_snapshot(component.clone(), snapshot.reference());
3910        assert!(snapshot.current().is_none());
3911
3912        let mut presented = node.with_style(&Style::new().opacity(0.5).unwrap());
3913        let owned = snapshot
3914            .current()
3915            .expect("first outer mutation activates snapshot");
3916        assert_eq!(Rc::strong_count(&snapshot.0), 1, "node link must stay weak");
3917        assert!(matches!(owned.kind(), UiNodeKind::Text { text } if text == "initial"));
3918        assert_eq!(owned.style().base.opacity, None);
3919
3920        let replacement =
3921            UiNode::text("updated").with_component_root_snapshot(component, snapshot.reference());
3922        snapshot.update_if_active(&replacement);
3923        presented.hydrate_component_subtrees();
3924        assert!(matches!(presented.kind(), UiNodeKind::Text { text } if text == "updated"));
3925        assert_eq!(presented.style().base.opacity, Some(0.5));
3926    }
3927
3928    #[test]
3929    fn inherited_motion_group_replays_across_nested_component_replacement() {
3930        let outer = ComponentInstancePath::root("Outer", "root");
3931        let inner = outer.child("Inner", "child");
3932        let inner_node = UiNode::text("initial")
3933            .with_shared_layout_id("item")
3934            .with_component_root(inner.clone());
3935        let mut root = UiNode::column(vec![inner_node]).with_component_root(outer);
3936
3937        apply_motion_group(&mut root, "outer-group", None);
3938        let UiNodeKind::Box { children } = root.kind() else {
3939            panic!("expected component column");
3940        };
3941        assert_eq!(
3942            children[0].attributes().get("shared_layout_group"),
3943            Some(&UiValue::String("outer-group".to_owned()))
3944        );
3945
3946        let replacement = UiNode::text("updated")
3947            .with_shared_layout_id("item")
3948            .with_component_root(inner.clone());
3949        assert!(root.replace_component_subtree(&inner, replacement));
3950        let UiNodeKind::Box { children } = root.kind() else {
3951            panic!("expected component column");
3952        };
3953        assert_eq!(
3954            children[0].attributes().get("shared_layout_group"),
3955            Some(&UiValue::String("outer-group".to_owned()))
3956        );
3957        assert!(matches!(children[0].kind(), UiNodeKind::Text { text } if text == "updated"));
3958    }
3959
3960    fn find_realized<'a>(
3961        node: &'a UiNode,
3962        id: &crate::VirtualCollectionId,
3963    ) -> Option<&'a BTreeMap<usize, UiNode>> {
3964        node.virtual_collection_items(id)
3965    }
3966
3967    #[test]
3968    fn realized_items_move_into_nested_collections_without_copies() {
3969        // The realized map must reach the matching collection as the same
3970        // allocation that was passed in, however deep the collection sits and
3971        // whatever siblings precede it. A copy per visited node is what made a
3972        // scroll step cost (nodes before the collection) x (realized items).
3973        let id = crate::VirtualCollectionId {
3974            component: crate::ComponentInstancePath::root("Table", "readers"),
3975            key: "rows".to_owned(),
3976        };
3977        let collection = || {
3978            UiNode::virtual_collection(crate::VirtualCollectionNodeSpec {
3979                id: id.clone(),
3980                label: "Rows".to_owned(),
3981                data: Vec::<UiValue>::new().into(),
3982                realized: BTreeMap::new(),
3983                estimated_height: 30.0,
3984                height: Some(600.0),
3985                overdraw_pixels: 120.0,
3986                bottom_align: false,
3987                follow_tail: false,
3988                reveal_key: None,
3989                sticky_headers: std::sync::Arc::new(std::collections::BTreeSet::new()),
3990                inherited_motion_group: None,
3991                inherited_motion_scope: None,
3992            })
3993        };
3994        let siblings = || {
3995            (0..8)
3996                .map(|index| UiNode::text(format!("sibling {index}")))
3997                .collect::<Vec<_>>()
3998        };
3999        let nested = |inner: UiNode| {
4000            let mut children = siblings();
4001            children.push(UiNode::box_node(vec![UiNode::fragment(vec![inner])]));
4002            UiNode::column(children)
4003        };
4004        let layouts = vec![
4005            nested(collection()),
4006            nested(UiNode::error_boundary(UiNode::text("child"), collection())),
4007            UiNode::row(vec![
4008                nested(UiNode::text("no collection here")),
4009                nested(collection()),
4010            ]),
4011            {
4012                let mut outer = collection();
4013                let UiNodeKind::VirtualCollection { spec } = &mut outer.kind else {
4014                    unreachable!()
4015                };
4016                spec.id.key = "outer".to_owned();
4017                spec.realized.insert(0, collection());
4018                outer
4019            },
4020            UiNode::custom(PrimitiveNode {
4021                primitive: crate::PrimitiveId::parse("test.slot").unwrap(),
4022                key: None,
4023                props: crate::PrimitiveProps::new().with(
4024                    "content",
4025                    crate::PrimitiveValue::Node(Box::new(collection())),
4026                ),
4027            }),
4028            UiNode::custom(PrimitiveNode {
4029                primitive: crate::PrimitiveId::parse("test.slots").unwrap(),
4030                key: None,
4031                props: crate::PrimitiveProps::new().with(
4032                    "content",
4033                    crate::PrimitiveValue::Nodes(vec![UiNode::text("prefix"), collection()]),
4034                ),
4035            }),
4036        ];
4037        for (index, mut root) in layouts.into_iter().enumerate() {
4038            let item = UiNode::text("row")
4039                .with_attribute("probe", UiValue::String(format!("row payload {index}")));
4040            // Address of the attribute as stored in the built item node.
4041            let Some(UiValue::String(payload)) = item.attributes().get("probe") else {
4042                panic!("layout {index}: probe attribute missing before replacement");
4043            };
4044            let payload_ptr = payload.as_ptr();
4045            assert!(root.replace_virtual_collection_items(&id, BTreeMap::from([(7, item)])));
4046            let realized = find_realized(&root, &id).expect("collection keeps the realized items");
4047            let Some(UiValue::String(landed)) = realized[&7].attributes().get("probe") else {
4048                panic!("layout {index}: realized item lost its probe attribute");
4049            };
4050            assert_eq!(
4051                landed.as_ptr(),
4052                payload_ptr,
4053                "layout {index}: realized items were copied"
4054            );
4055        }
4056    }
4057
4058    #[test]
4059    fn key_handler_names_accept_punctuation_keys() {
4060        // gpui dispatches punctuation keys as their literal key string (`?`,
4061        // `/`, `[`, `-`, `=`), so handlers for them must be registrable both
4062        // through the sugar and through the generic `on`.
4063        for script in [
4064            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"?\", Fn(\"h\"), ()) }",
4065            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"[\", Fn(\"h\"), ()) }",
4066            "fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:/\", Fn(\"h\")) }",
4067            "fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:-\", Fn(\"h\")) }",
4068            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"escape\", Fn(\"h\"), ()) }",
4069        ] {
4070            let mut runtime = crate::RuntimeEngine::new();
4071            let compiled = runtime.compile_named("keys.rhai", script).unwrap();
4072            runtime.render(&compiled).unwrap_or_else(|error| {
4073                panic!("script should register its key handler: {script}\n{error}")
4074            });
4075        }
4076    }
4077
4078    #[test]
4079    fn key_handler_names_reject_invalid_key_names() {
4080        for script in [
4081            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"\", Fn(\"h\"), ()) }",
4082            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"a b\", Fn(\"h\"), ()) }",
4083            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"héllo\", Fn(\"h\"), ()) }",
4084            "fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"a:b\", Fn(\"h\"), ()) }",
4085            "fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:\", Fn(\"h\")) }",
4086            "fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:a b\", Fn(\"h\")) }",
4087        ] {
4088            let mut runtime = crate::RuntimeEngine::new();
4089            let compiled = runtime.compile_named("keys.rhai", script).unwrap();
4090            assert!(
4091                runtime.render(&compiled).is_err(),
4092                "script should be rejected: {script}"
4093            );
4094        }
4095    }
4096
4097    fn key_registration_script(method: &str, key: &str) -> String {
4098        let name = if method == "on_key_value" {
4099            key.to_owned()
4100        } else {
4101            format!("key:{key}")
4102        };
4103        let name = serde_json::to_string(&name).unwrap();
4104        let payload = if method == "on_key_value" { ", 7" } else { "" };
4105        format!(
4106            "fn h(ctx,p){{()}} fn view(){{text(\"target\").{method}({name},Fn(\"h\"){payload})}}"
4107        )
4108    }
4109
4110    #[test]
4111    fn all_rhai_key_entries_share_normalization_and_exact_limits() {
4112        for (method, phase) in [
4113            ("on_key_value", crate::EventPhase::Target),
4114            ("on", crate::EventPhase::Target),
4115            ("on_capture", crate::EventPhase::Capture),
4116            ("on_bubble", crate::EventPhase::Bubble),
4117        ] {
4118            let long = "A".repeat(60);
4119            for key in [
4120                "Escape", "A", "1", "page_up", "?", "/", "[", "]", "-", "=", &long,
4121            ] {
4122                let mut runtime = crate::RuntimeEngine::new();
4123                let compiled = runtime
4124                    .compile_named("key_contract.rhai", &key_registration_script(method, key))
4125                    .unwrap();
4126                let node = runtime.render(&compiled).unwrap();
4127                let event = format!("key:{}", key.to_ascii_lowercase());
4128                assert_eq!(node.handlers().len(), 1, "{method}, {key:?}");
4129                assert_eq!(node.event_handlers(&event)[0].phase(), phase);
4130                if method == "on_key_value" {
4131                    assert_eq!(
4132                        node.event_handlers(&event)[0].value(),
4133                        Some(&UiValue::Integer(7))
4134                    );
4135                }
4136            }
4137            for (key, canonical) in [
4138                ("Shift+F6", "shift+f6"),
4139                ("cmd+shift+k", "shift+cmd+k"),
4140                ("ctrl+alt+/", "ctrl+alt+/"),
4141                ("+", "+"),
4142            ] {
4143                let mut runtime = crate::RuntimeEngine::new();
4144                let compiled = runtime
4145                    .compile_named("key_contract.rhai", &key_registration_script(method, key))
4146                    .unwrap();
4147                let node = runtime.render(&compiled).unwrap();
4148                let event = format!("key:{canonical}");
4149                assert_eq!(node.event_handlers(&event)[0].phase(), phase, "{key:?}");
4150            }
4151            let long = "a".repeat(61);
4152            for key in [
4153                "",
4154                " ",
4155                " escape",
4156                "escape ",
4157                "\t",
4158                "\n",
4159                "\0",
4160                ":",
4161                "a:b",
4162                "中文",
4163                "é",
4164                "cmd-s",
4165                "shift-/",
4166                "??",
4167                "[]",
4168                &long,
4169                "shift+shift+a",
4170                "hyper+a",
4171                "shift+",
4172                "+a",
4173                "ctrl++",
4174            ] {
4175                let mut runtime = crate::RuntimeEngine::new();
4176                let compiled = runtime
4177                    .compile_named("key_contract.rhai", &key_registration_script(method, key))
4178                    .unwrap();
4179                assert!(
4180                    runtime.render(&compiled).is_err(),
4181                    "{method} accepted {key:?}"
4182                );
4183            }
4184        }
4185    }
4186
4187    #[test]
4188    fn key_normalization_does_not_change_ordinary_event_namespaces() {
4189        for event in ["click", "value_changed", "custom:event", ":", "::"] {
4190            assert_eq!(normalize_node_event_name(event).unwrap(), event);
4191        }
4192        for event in [
4193            "",
4194            "Click",
4195            "custom:Event",
4196            "Key:Escape",
4197            "custom-event",
4198            "custom:?",
4199            "a b",
4200        ] {
4201            assert!(
4202                normalize_node_event_name(event).is_err(),
4203                "accepted {event:?}"
4204            );
4205        }
4206        assert!(normalize_node_event_name(&"a".repeat(64)).is_ok());
4207        assert!(normalize_node_event_name(&"a".repeat(65)).is_err());
4208    }
4209}