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