Skip to main content

gpui_rhai/
primitive.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4use std::panic::{AssertUnwindSafe, catch_unwind};
5use std::rc::{Rc, Weak};
6
7use gpui::{AnyElement, App, IntoElement, ParentElement, RenderOnce, Window, div};
8use rhai::{Array, Dynamic, FnPtr, Map};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::{
13    AssetId, ColorResolver, ColorValue, ComponentStateSchema, EventSchema, Length,
14    NodeEventDispatcher, ObjectField, RadiusToken, Rgba8, SchemaDefinitionError,
15    SchemaValidationError, ScriptCallback, ScriptGeneration, SpacingToken, Style, UiEventHandler,
16    UiNode, UiValue, UiValueError, ValueSchema,
17};
18
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
20#[serde(try_from = "String", into = "String")]
21pub struct PrimitiveId(String);
22
23impl PrimitiveId {
24    /// Parse a namespaced primitive ID such as `my_app.code_editor`.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`PrimitiveError::InvalidId`] for invalid identifiers.
29    pub fn parse(value: impl Into<String>) -> Result<Self, PrimitiveError> {
30        let value = value.into();
31        if value
32            .split_once('.')
33            .is_some_and(|(namespace, name)| is_identifier(namespace) && is_identifier(name))
34        {
35            Ok(Self(value))
36        } else {
37            Err(PrimitiveError::InvalidId(value))
38        }
39    }
40
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    #[must_use]
47    pub fn namespace(&self) -> &str {
48        self.0
49            .split_once('.')
50            .map_or("", |(namespace, _)| namespace)
51    }
52}
53
54impl TryFrom<String> for PrimitiveId {
55    type Error = PrimitiveError;
56
57    fn try_from(value: String) -> Result<Self, Self::Error> {
58        Self::parse(value)
59    }
60}
61
62impl From<PrimitiveId> for String {
63    fn from(value: PrimitiveId) -> Self {
64        value.0
65    }
66}
67
68fn is_identifier(value: &str) -> bool {
69    !value.is_empty()
70        && !value.starts_with('_')
71        && !value.ends_with('_')
72        && !value.contains("__")
73        && value.chars().all(|character| {
74            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
75        })
76}
77
78fn is_pascal_case(value: &str) -> bool {
79    value
80        .chars()
81        .next()
82        .is_some_and(|character| character.is_ascii_uppercase())
83        && value
84            .chars()
85            .all(|character| character.is_ascii_alphanumeric())
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct PrimitiveDescriptor {
90    pub id: PrimitiveId,
91    pub export: String,
92    #[serde(default)]
93    pub props: BTreeMap<String, ObjectField>,
94    #[serde(default)]
95    pub events: BTreeMap<String, EventSchema>,
96    #[serde(default)]
97    pub state: ComponentStateSchema,
98    #[serde(default)]
99    pub lifecycle: bool,
100}
101
102#[derive(Clone, Debug, PartialEq)]
103pub enum PrimitiveValue {
104    Data(UiValue),
105    Node(Box<UiNode>),
106    Nodes(Vec<UiNode>),
107    Callback(UiEventHandler),
108    Style(Box<Style>),
109    Length(Length),
110    Asset(AssetId),
111    Signal(crate::NativeSignal),
112    Ref(crate::ElementRef),
113    Document(crate::NativeTextDocument),
114}
115
116/// Read-only semantic theme values captured for one native primitive render.
117///
118/// Primitive handlers use this snapshot to resolve component-owned paint parts
119/// without receiving a mutable application/theme manager or coupling to a
120/// concrete theme family.
121#[derive(Clone, Debug)]
122pub struct PrimitiveTheme {
123    colors: BTreeMap<String, Rgba8>,
124    spacing: BTreeMap<SpacingToken, Length>,
125    radii: BTreeMap<RadiusToken, Length>,
126    typography: BTreeMap<String, crate::ResolvedTypography>,
127    direction: crate::TextDirection,
128}
129
130impl Default for PrimitiveTheme {
131    fn default() -> Self {
132        Self {
133            colors: BTreeMap::new(),
134            spacing: BTreeMap::new(),
135            radii: BTreeMap::new(),
136            typography: BTreeMap::new(),
137            direction: crate::TextDirection::LeftToRight,
138        }
139    }
140}
141
142impl PrimitiveTheme {
143    #[cfg(test)]
144    pub(crate) fn capture(colors: &impl ColorResolver) -> Self {
145        Self::capture_with_direction(colors, crate::TextDirection::LeftToRight)
146    }
147
148    pub(crate) fn capture_with_direction(
149        colors: &impl ColorResolver,
150        direction: crate::TextDirection,
151    ) -> Self {
152        const TOKENS: &[&str] = &[
153            "surface",
154            "surface_raised",
155            "surface_hover",
156            "text_primary",
157            "text_muted",
158            "accent",
159            "accent_hover",
160            "on_accent",
161            "danger",
162            "on_danger",
163            "warning",
164            "on_warning",
165            "success",
166            "on_success",
167            "border",
168            "focus_ring",
169            "disabled",
170            "syntax.comment",
171            "syntax.string",
172            "syntax.number",
173            "syntax.keyword",
174            "syntax.function",
175            "syntax.type",
176            "syntax.variable",
177            "syntax.constant",
178            "syntax.operator",
179            "syntax.punctuation",
180            "syntax.tag",
181            "syntax.attribute",
182            "document.search_match",
183            "document.search_current",
184            "diff.left_only",
185            "diff.right_only",
186            "diff.modified",
187            "diff.inline_left",
188            "diff.inline_right",
189            "diff.gutter",
190            "diff.fold",
191        ];
192        Self {
193            colors: TOKENS
194                .iter()
195                .filter_map(|token| {
196                    colors
197                        .resolve(&ColorValue::Token((*token).to_owned()))
198                        .map(|value| ((*token).to_owned(), value))
199                })
200                .collect(),
201            spacing: [
202                SpacingToken::Xs,
203                SpacingToken::Sm,
204                SpacingToken::Md,
205                SpacingToken::Lg,
206            ]
207            .into_iter()
208            .filter_map(|token| {
209                colors
210                    .resolve_length(Length::ThemeSpacing(token))
211                    .map(|value| (token, value))
212            })
213            .collect(),
214            radii: [RadiusToken::Sm, RadiusToken::Md, RadiusToken::Lg]
215                .into_iter()
216                .filter_map(|token| {
217                    colors
218                        .resolve_length(Length::ThemeRadius(token))
219                        .map(|value| (token, value))
220                })
221                .collect(),
222            typography: crate::REQUIRED_TYPOGRAPHY
223                .iter()
224                .filter_map(|role| {
225                    colors
226                        .resolve_typography(role)
227                        .map(|value| ((*role).to_owned(), value))
228                })
229                .collect(),
230            direction,
231        }
232    }
233
234    #[must_use]
235    pub fn color(&self, token: &str) -> Option<Rgba8> {
236        self.colors.get(token).copied()
237    }
238
239    #[must_use]
240    pub fn resolve_color(&self, value: &ColorValue) -> Option<Rgba8> {
241        match value {
242            ColorValue::Literal(value) => Some(*value),
243            ColorValue::Token(token) => self.color(token),
244        }
245    }
246
247    #[must_use]
248    pub fn resolve_length(&self, value: Length) -> Option<Length> {
249        match value {
250            Length::ThemeSpacing(token) => self.spacing.get(&token).copied(),
251            Length::ThemeRadius(token) => self.radii.get(&token).copied(),
252            Length::Pixels(_) | Length::Rems(_) | Length::Relative(_) => Some(value),
253        }
254    }
255
256    #[must_use]
257    pub const fn direction(&self) -> crate::TextDirection {
258        self.direction
259    }
260
261    #[must_use]
262    pub fn typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
263        self.typography.get(role).cloned()
264    }
265}
266
267impl ColorResolver for PrimitiveTheme {
268    fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
269        self.resolve_color(color)
270    }
271
272    fn resolve_length(&self, length: Length) -> Option<Length> {
273        PrimitiveTheme::resolve_length(self, length)
274    }
275
276    fn resolve_typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
277        self.typography(role)
278    }
279}
280
281#[derive(Clone, Debug, Default, PartialEq)]
282pub struct PrimitiveProps(BTreeMap<String, PrimitiveValue>);
283
284impl PrimitiveProps {
285    #[must_use]
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    #[must_use]
291    pub fn get(&self, name: &str) -> Option<&PrimitiveValue> {
292        self.0.get(name)
293    }
294
295    pub fn insert(
296        &mut self,
297        name: impl Into<String>,
298        value: PrimitiveValue,
299    ) -> Option<PrimitiveValue> {
300        self.0.insert(name.into(), value)
301    }
302
303    #[must_use]
304    pub fn with(mut self, name: impl Into<String>, value: PrimitiveValue) -> Self {
305        self.insert(name, value);
306        self
307    }
308
309    pub fn iter(&self) -> impl Iterator<Item = (&str, &PrimitiveValue)> {
310        self.0.iter().map(|(name, value)| (name.as_str(), value))
311    }
312
313    pub(crate) fn iter_mut(&mut self) -> impl Iterator<Item = (&str, &mut PrimitiveValue)> {
314        self.0
315            .iter_mut()
316            .map(|(name, value)| (name.as_str(), value))
317    }
318
319    pub(crate) fn bind_component_scope(
320        &mut self,
321        component: &crate::ComponentInstancePath,
322        incarnation: crate::ComponentIncarnation,
323        events: &BTreeMap<String, EventSchema>,
324        native_context: Option<&crate::invocation::ScriptInvocationContext>,
325    ) {
326        for value in self.0.values_mut() {
327            match value {
328                PrimitiveValue::Callback(callback) => {
329                    if let Some(callback) = callback.as_script_mut() {
330                        callback.bind_component_scope_if_unset(
331                            component,
332                            incarnation,
333                            events.clone(),
334                        );
335                        if let Some(context) = native_context {
336                            callback.bind_native_context_if_unset(context.clone());
337                        }
338                    }
339                }
340                PrimitiveValue::Node(node) => {
341                    node.bind_component_scope(component, incarnation, events, native_context);
342                }
343                PrimitiveValue::Nodes(nodes) => {
344                    for node in nodes {
345                        node.bind_component_scope(component, incarnation, events, native_context);
346                    }
347                }
348                PrimitiveValue::Data(_)
349                | PrimitiveValue::Style(_)
350                | PrimitiveValue::Length(_)
351                | PrimitiveValue::Asset(_)
352                | PrimitiveValue::Signal(_)
353                | PrimitiveValue::Ref(_)
354                | PrimitiveValue::Document(_) => {}
355            }
356        }
357    }
358}
359
360#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
361pub struct PrimitiveInstanceId {
362    primitive: PrimitiveId,
363    key: String,
364    node: crate::NodeId,
365}
366
367impl PrimitiveInstanceId {
368    #[must_use]
369    pub const fn primitive(&self) -> &PrimitiveId {
370        &self.primitive
371    }
372
373    #[must_use]
374    pub fn key(&self) -> &str {
375        &self.key
376    }
377
378    #[must_use]
379    pub const fn node(&self) -> crate::NodeId {
380        self.node
381    }
382}
383
384#[derive(Clone, Debug, PartialEq)]
385pub struct PrimitiveNode {
386    pub primitive: PrimitiveId,
387    pub key: Option<String>,
388    pub props: PrimitiveProps,
389}
390
391#[derive(Clone, Debug)]
392pub struct PrimitiveInstance {
393    pub id: Option<PrimitiveInstanceId>,
394    pub node: PrimitiveNode,
395    resources: Option<PrimitiveResourceScope>,
396}
397
398impl PrimitiveInstance {
399    /// Return the runtime-owned cancellation scope for a retained instance.
400    /// Ephemeral primitives have no resource scope and must not start durable
401    /// work from render.
402    #[must_use]
403    pub const fn resources(&self) -> Option<&PrimitiveResourceScope> {
404        self.resources.as_ref()
405    }
406}
407
408#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
409pub struct PrimitiveResourceHandle(u64);
410
411struct PrimitiveResourceEntry {
412    label: String,
413    cleanup: Option<Box<dyn FnOnce()>>,
414}
415
416#[derive(Default)]
417struct PrimitiveResourceState {
418    next_id: u64,
419    entries: BTreeMap<u64, PrimitiveResourceEntry>,
420}
421
422impl Drop for PrimitiveResourceState {
423    fn drop(&mut self) {
424        let entries = std::mem::take(&mut self.entries);
425        for entry in entries.into_values().rev() {
426            let _ = run_resource_cleanup(entry);
427        }
428    }
429}
430
431#[derive(Clone, Default)]
432pub struct PrimitiveResourceScope {
433    inner: Rc<RefCell<PrimitiveResourceState>>,
434}
435
436impl fmt::Debug for PrimitiveResourceScope {
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        match self.inner.try_borrow() {
439            Ok(state) => formatter
440                .debug_struct("PrimitiveResourceScope")
441                .field(
442                    "active",
443                    &state
444                        .entries
445                        .values()
446                        .map(|entry| entry.label.as_str())
447                        .collect::<Vec<_>>(),
448                )
449                .finish(),
450            Err(_) => formatter.write_str("PrimitiveResourceScope(<borrowed>)"),
451        }
452    }
453}
454
455impl PrimitiveResourceScope {
456    #[must_use]
457    pub fn new() -> Self {
458        Self::default()
459    }
460
461    /// Own one native task/subscription/resource cancellation callback.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`PrimitiveResourceError`] for an unsafe label or conflicting
466    /// scope borrow.
467    pub fn own(
468        &self,
469        label: impl Into<String>,
470        cleanup: impl FnOnce() + 'static,
471    ) -> Result<PrimitiveResourceHandle, PrimitiveResourceError> {
472        let label = label.into();
473        if label.is_empty()
474            || label.len() > 128
475            || !label.chars().all(|character| {
476                character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
477            })
478        {
479            return Err(PrimitiveResourceError::InvalidLabel(label));
480        }
481        let mut state = self
482            .inner
483            .try_borrow_mut()
484            .map_err(|_| PrimitiveResourceError::Borrowed)?;
485        let id = state.next_id.max(1);
486        state.next_id = id
487            .checked_add(1)
488            .ok_or(PrimitiveResourceError::IdExhausted)?;
489        state.entries.insert(
490            id,
491            PrimitiveResourceEntry {
492                label,
493                cleanup: Some(Box::new(cleanup)),
494            },
495        );
496        Ok(PrimitiveResourceHandle(id))
497    }
498
499    /// Cancel one owned resource early.
500    ///
501    /// # Errors
502    ///
503    /// Returns a borrow or cleanup-panic diagnostic.
504    pub fn cancel(&self, handle: &PrimitiveResourceHandle) -> Result<bool, PrimitiveResourceError> {
505        let entry = self
506            .inner
507            .try_borrow_mut()
508            .map_err(|_| PrimitiveResourceError::Borrowed)?
509            .entries
510            .remove(&handle.0);
511        let Some(entry) = entry else {
512            return Ok(false);
513        };
514        run_resource_cleanup(entry)?;
515        Ok(true)
516    }
517
518    #[must_use]
519    pub fn active_count(&self) -> usize {
520        self.inner.borrow().entries.len()
521    }
522
523    fn checkpoint(&self) -> Result<u64, PrimitiveResourceError> {
524        self.inner
525            .try_borrow()
526            .map(|state| state.next_id.max(1))
527            .map_err(|_| PrimitiveResourceError::Borrowed)
528    }
529
530    fn rollback(&self, checkpoint: u64) -> Result<(), PrimitiveResourceError> {
531        self.cleanup_where(|id| id >= checkpoint)
532    }
533
534    fn close(&self) -> Result<(), PrimitiveResourceError> {
535        self.cleanup_where(|_| true)
536    }
537
538    fn cleanup_where(&self, predicate: impl Fn(u64) -> bool) -> Result<(), PrimitiveResourceError> {
539        let mut entries = {
540            let mut state = self
541                .inner
542                .try_borrow_mut()
543                .map_err(|_| PrimitiveResourceError::Borrowed)?;
544            let ids = state
545                .entries
546                .keys()
547                .copied()
548                .filter(|id| predicate(*id))
549                .collect::<Vec<_>>();
550            ids.into_iter()
551                .rev()
552                .filter_map(|id| state.entries.remove(&id))
553                .collect::<Vec<_>>()
554        };
555        let mut first_error = None;
556        for entry in entries.drain(..) {
557            if let Err(error) = run_resource_cleanup(entry)
558                && first_error.is_none()
559            {
560                first_error = Some(error);
561            }
562        }
563        first_error.map_or(Ok(()), Err)
564    }
565}
566
567fn run_resource_cleanup(mut entry: PrimitiveResourceEntry) -> Result<(), PrimitiveResourceError> {
568    let label = entry.label;
569    let Some(cleanup) = entry.cleanup.take() else {
570        return Ok(());
571    };
572    catch_unwind(AssertUnwindSafe(cleanup))
573        .map_err(|_| PrimitiveResourceError::CleanupPanic { label })
574}
575
576#[derive(Clone, Debug, Error, Eq, PartialEq)]
577pub enum PrimitiveResourceError {
578    #[error("primitive resource label `{0}` must be 1-128 safe ASCII characters")]
579    InvalidLabel(String),
580    #[error("primitive resource scope is already borrowed")]
581    Borrowed,
582    #[error("primitive resource scope exhausted its handle identity space")]
583    IdExhausted,
584    #[error("primitive resource cleanup `{label}` panicked")]
585    CleanupPanic { label: String },
586}
587
588#[derive(Clone)]
589pub struct PrimitiveEventEmitter {
590    registry: Weak<RefCell<PrimitiveRegistryInner>>,
591    primitive: PrimitiveId,
592    callbacks: BTreeMap<String, UiEventHandler>,
593    dispatcher: Option<NodeEventDispatcher>,
594}
595
596impl PrimitiveEventEmitter {
597    /// Normalize and dispatch a declared native primitive event.
598    ///
599    /// # Errors
600    ///
601    /// Returns schema and event declaration errors. Missing callback props are
602    /// treated as an intentionally unobserved event.
603    pub fn emit(
604        &self,
605        event: &str,
606        payload: UiValue,
607        window: &mut Window,
608        cx: &mut App,
609    ) -> Result<(), PrimitiveError> {
610        let registry = PrimitiveRegistry {
611            inner: self
612                .registry
613                .upgrade()
614                .ok_or(PrimitiveError::RegistryReleased)?,
615        };
616        let payload = registry.normalize_event(&self.primitive, event, payload)?;
617        if let Some(handler) = self.callbacks.get(event) {
618            match handler {
619                UiEventHandler::Script(callback) => {
620                    if let Some(dispatcher) = self.dispatcher.as_ref() {
621                        dispatcher.dispatch(callback.clone(), payload, None, window, cx);
622                    }
623                }
624                UiEventHandler::Host(callback) => {
625                    callback.invoke(payload, window, cx);
626                }
627                UiEventHandler::Native(reference) => {
628                    if let Some(dispatcher) = self.dispatcher.as_ref() {
629                        dispatcher.dispatch_native(
630                            reference.clone(),
631                            event.to_owned(),
632                            payload,
633                            None,
634                            window,
635                            cx,
636                        );
637                    }
638                }
639            }
640        }
641        Ok(())
642    }
643
644    /// Write one primitive-owned native signal without invoking Rhai.
645    ///
646    /// # Errors
647    ///
648    /// Returns a stale or type error when the signal no longer belongs to the
649    /// mounted component instance.
650    pub fn write_signal(
651        &self,
652        signal: &crate::NativeSignal,
653        value: crate::SignalValue,
654        cx: &mut App,
655    ) -> Result<bool, crate::SignalError> {
656        self.dispatcher.as_ref().map_or_else(
657            || Err(crate::SignalError::Stale(signal.id().clone())),
658            |dispatcher| dispatcher.write_signal(signal.clone(), value, cx),
659        )
660    }
661
662    /// Read the last committed layout bounds for a primitive-owned element ref.
663    #[must_use]
664    pub fn element_bounds(
665        &self,
666        reference: &crate::ElementRef,
667        cx: &App,
668    ) -> Option<crate::GeometryBounds> {
669        self.dispatcher
670            .as_ref()
671            .and_then(|dispatcher| dispatcher.element_bounds(reference, cx))
672    }
673}
674
675pub trait PrimitiveHandler {
676    /// Called once before the first render of a keyed lifecycle primitive.
677    ///
678    /// # Errors
679    ///
680    /// Returns a diagnostic message when native setup fails.
681    fn mount(&mut self, _instance: &PrimitiveInstance) -> Result<(), String> {
682        Ok(())
683    }
684
685    /// Apply a validated prop/style/event snapshot to an existing keyed instance.
686    ///
687    /// # Errors
688    ///
689    /// Returns a diagnostic message when the retained native update fails.
690    fn update(
691        &mut self,
692        _previous: &PrimitiveInstance,
693        _next: &PrimitiveInstance,
694    ) -> Result<(), String> {
695        Ok(())
696    }
697
698    /// Render the primitive into a native GPUI element.
699    ///
700    /// # Errors
701    ///
702    /// Returns a diagnostic message when native rendering fails.
703    fn render(
704        &mut self,
705        instance: &PrimitiveInstance,
706        events: &PrimitiveEventEmitter,
707        theme: &PrimitiveTheme,
708        window: &mut Window,
709        cx: &mut App,
710    ) -> Result<AnyElement, String>;
711
712    /// Called when a previously mounted keyed primitive is no longer reachable.
713    fn unmount(&mut self, _instance: &PrimitiveInstanceId) {}
714}
715
716struct PrimitiveEntry {
717    descriptor: PrimitiveDescriptor,
718    handler: Box<dyn PrimitiveHandler>,
719}
720
721#[derive(Default)]
722struct PrimitiveRegistryInner {
723    entries: BTreeMap<PrimitiveId, PrimitiveEntry>,
724    mounted: BTreeMap<PrimitiveInstanceId, PrimitiveInstance>,
725}
726
727#[derive(Clone, Default)]
728pub struct PrimitiveRegistry {
729    inner: Rc<RefCell<PrimitiveRegistryInner>>,
730}
731
732impl fmt::Debug for PrimitiveRegistry {
733    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
734        match self.inner.try_borrow() {
735            Ok(inner) => formatter
736                .debug_struct("PrimitiveRegistry")
737                .field("registered", &inner.entries.keys().collect::<Vec<_>>())
738                .field("mounted", &inner.mounted.keys().collect::<Vec<_>>())
739                .finish(),
740            Err(_) => formatter.write_str("PrimitiveRegistry(<borrowed>)"),
741        }
742    }
743}
744
745impl PrimitiveRegistry {
746    #[must_use]
747    pub fn new() -> Self {
748        Self::default()
749    }
750
751    /// Register a validated primitive descriptor and native handler.
752    ///
753    /// # Errors
754    ///
755    /// Returns [`PrimitiveError`] for duplicate IDs, invalid exports/defaults,
756    /// or missing callback props for declared events.
757    pub fn register(
758        &self,
759        descriptor: PrimitiveDescriptor,
760        handler: impl PrimitiveHandler + 'static,
761    ) -> Result<(), PrimitiveError> {
762        validate_descriptor(&descriptor)?;
763        let mut inner = self
764            .inner
765            .try_borrow_mut()
766            .map_err(|_| PrimitiveError::Borrowed)?;
767        if inner.entries.contains_key(&descriptor.id) {
768            return Err(PrimitiveError::Duplicate(descriptor.id));
769        }
770        if inner.entries.values().any(|entry| {
771            entry.descriptor.id.namespace() == descriptor.id.namespace()
772                && entry.descriptor.export == descriptor.export
773        }) {
774            return Err(PrimitiveError::DuplicateExport {
775                namespace: descriptor.id.namespace().to_owned(),
776                export: descriptor.export,
777            });
778        }
779        inner.entries.insert(
780            descriptor.id.clone(),
781            PrimitiveEntry {
782                descriptor,
783                handler: Box::new(handler),
784            },
785        );
786        Ok(())
787    }
788
789    /// Convert validated Rhai props into a custom `UiNode`.
790    ///
791    /// # Errors
792    ///
793    /// Returns [`PrimitiveError`] for unknown primitives, invalid props, or a
794    /// missing key on stateful/lifecycle instances.
795    pub fn create_node(
796        &self,
797        id: &PrimitiveId,
798        key: Option<String>,
799        props: &Map,
800        generation: ScriptGeneration,
801    ) -> Result<UiNode, PrimitiveError> {
802        let inner = self
803            .inner
804            .try_borrow()
805            .map_err(|_| PrimitiveError::Borrowed)?;
806        let descriptor = &inner
807            .entries
808            .get(id)
809            .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
810            .descriptor;
811        if (descriptor.lifecycle || !descriptor.state.is_empty()) && key.is_none() {
812            return Err(PrimitiveError::MissingKey(id.clone()));
813        }
814        let schema = ValueSchema::object(descriptor.props.clone());
815        schema
816            .validate(&Dynamic::from_map(props.clone()))
817            .map_err(|source| PrimitiveError::InvalidProps {
818                primitive: id.clone(),
819                source,
820            })?;
821        let props = convert_props(&descriptor.props, props, generation)?;
822        Ok(UiNode::custom(PrimitiveNode {
823            primitive: id.clone(),
824            key,
825            props,
826        }))
827    }
828
829    /// Validate an event emitted by a native primitive adapter.
830    ///
831    /// # Errors
832    ///
833    /// Returns [`PrimitiveError`] for unknown events or invalid payloads.
834    pub fn normalize_event(
835        &self,
836        id: &PrimitiveId,
837        event: &str,
838        payload: UiValue,
839    ) -> Result<UiValue, PrimitiveError> {
840        let inner = self
841            .inner
842            .try_borrow()
843            .map_err(|_| PrimitiveError::Borrowed)?;
844        let descriptor = &inner
845            .entries
846            .get(id)
847            .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
848            .descriptor;
849        let schema = descriptor
850            .events
851            .get(event)
852            .ok_or_else(|| PrimitiveError::UnknownEvent {
853                primitive: id.clone(),
854                event: event.to_owned(),
855            })?;
856        schema
857            .payload
858            .validate_ui_value(&payload)
859            .map_err(|source| PrimitiveError::InvalidEvent {
860                primitive: id.clone(),
861                event: event.to_owned(),
862                source,
863            })?;
864        Ok(payload)
865    }
866
867    /// Unmount keyed lifecycle instances absent from the successful node tree.
868    ///
869    /// # Errors
870    ///
871    /// Returns [`PrimitiveError::Borrowed`] during a conflicting render borrow.
872    pub fn retain_mounted(
873        &self,
874        active: &BTreeSet<PrimitiveInstanceId>,
875    ) -> Result<(), PrimitiveError> {
876        let mut inner = self
877            .inner
878            .try_borrow_mut()
879            .map_err(|_| PrimitiveError::Borrowed)?;
880        let removed = inner
881            .mounted
882            .keys()
883            .filter(|instance| !active.contains(*instance))
884            .cloned()
885            .collect::<Vec<_>>();
886        let mut first_error = None;
887        for instance in &removed {
888            let resources = inner
889                .mounted
890                .get(instance)
891                .and_then(|mounted| mounted.resources.clone());
892            if let Some(entry) = inner.entries.get_mut(&instance.primitive)
893                && let Err(error) = guard_primitive_panic(&instance.primitive, "unmount", || {
894                    entry.handler.unmount(instance);
895                })
896                && first_error.is_none()
897            {
898                first_error = Some(error);
899            }
900            if let Some(resources) = resources
901                && let Err(error) = resources.close()
902                && first_error.is_none()
903            {
904                first_error = Some(PrimitiveError::Resource(error));
905            }
906            inner.mounted.remove(instance);
907        }
908        first_error.map_or(Ok(()), Err)
909    }
910
911    /// Unmount keyed primitive instances absent from the current successful tree.
912    ///
913    /// # Errors
914    ///
915    /// Returns borrow or panic-boundary errors from native unmount handlers.
916    pub fn retain_tree(&self, tree: &crate::RetainedUiTree) -> Result<(), PrimitiveError> {
917        let active = collect_primitive_instances(tree);
918        self.retain_mounted(&active)
919    }
920
921    pub(crate) fn element(
922        &self,
923        node: PrimitiveNode,
924        retained_id: Option<crate::NodeId>,
925        fallback: Option<UiNode>,
926        dispatcher: Option<NodeEventDispatcher>,
927        theme: PrimitiveTheme,
928    ) -> AnyElement {
929        RegisteredPrimitiveElement {
930            registry: self.clone(),
931            node,
932            retained_id,
933            fallback,
934            dispatcher,
935            theme,
936        }
937        .into_any_element()
938    }
939
940    fn render_instance(
941        &self,
942        node: PrimitiveNode,
943        retained_id: Option<crate::NodeId>,
944        events: &PrimitiveEventEmitter,
945        theme: &PrimitiveTheme,
946        window: &mut Window,
947        cx: &mut App,
948    ) -> Result<AnyElement, PrimitiveError> {
949        let mut inner = self
950            .inner
951            .try_borrow_mut()
952            .map_err(|_| PrimitiveError::Borrowed)?;
953        let retained_instance = primitive_is_retained(&inner, &node.primitive)?;
954        if retained_instance && retained_id.is_none() {
955            return Err(PrimitiveError::MissingRetainedIdentity(node.primitive));
956        }
957        let instance_id = retained_instance.then(|| PrimitiveInstanceId {
958            primitive: node.primitive.clone(),
959            key: node
960                .key
961                .clone()
962                .expect("retained primitive descriptors require a key"),
963            node: retained_id.expect("retained primitive renderer supplies NodeId"),
964        });
965        let previous = instance_id
966            .as_ref()
967            .and_then(|id| inner.mounted.get(id))
968            .cloned();
969        let resources = if retained_instance {
970            Some(
971                previous
972                    .as_ref()
973                    .and_then(|instance| instance.resources.clone())
974                    .unwrap_or_default(),
975            )
976        } else {
977            None
978        };
979        let checkpoint = resources
980            .as_ref()
981            .map(PrimitiveResourceScope::checkpoint)
982            .transpose()?;
983        let instance = PrimitiveInstance {
984            id: instance_id.clone(),
985            node,
986            resources: resources.clone(),
987        };
988        let needs_mount = instance_id.is_some() && previous.is_none();
989        let entry = inner
990            .entries
991            .get_mut(&instance.node.primitive)
992            .ok_or_else(|| PrimitiveError::Unknown(instance.node.primitive.clone()))?;
993        let operation = (|| {
994            if needs_mount {
995                guard_primitive_panic(&instance.node.primitive, "mount", || {
996                    entry.handler.mount(&instance)
997                })?
998                .map_err(|message| PrimitiveError::Handler {
999                    primitive: instance.node.primitive.clone(),
1000                    message,
1001                })?;
1002            }
1003            if let Some(previous) = &previous
1004                && previous.node != instance.node
1005            {
1006                guard_primitive_panic(&instance.node.primitive, "update", || {
1007                    entry.handler.update(previous, &instance)
1008                })?
1009                .map_err(|message| PrimitiveError::Handler {
1010                    primitive: instance.node.primitive.clone(),
1011                    message,
1012                })?;
1013            }
1014            guard_primitive_panic(&instance.node.primitive, "render", || {
1015                entry.handler.render(&instance, events, theme, window, cx)
1016            })?
1017            .map_err(|message| PrimitiveError::Handler {
1018                primitive: instance.node.primitive.clone(),
1019                message,
1020            })
1021        })();
1022        let element = match operation {
1023            Ok(element) => element,
1024            Err(error) => {
1025                return Err(rollback_failed_primitive_operation(
1026                    entry,
1027                    &instance,
1028                    needs_mount,
1029                    resources.as_ref(),
1030                    checkpoint,
1031                    error,
1032                ));
1033            }
1034        };
1035        if let Some(id) = instance_id {
1036            inner.mounted.insert(id, instance);
1037        }
1038        Ok(element)
1039    }
1040}
1041
1042fn rollback_failed_primitive_operation(
1043    entry: &mut PrimitiveEntry,
1044    instance: &PrimitiveInstance,
1045    needs_unmount: bool,
1046    resources: Option<&PrimitiveResourceScope>,
1047    checkpoint: Option<u64>,
1048    original: PrimitiveError,
1049) -> PrimitiveError {
1050    let mut rollback_error = None;
1051    if needs_unmount
1052        && let Some(instance_id) = instance.id.as_ref()
1053        && let Err(error) =
1054            guard_primitive_panic(&instance.node.primitive, "failed-mount unmount", || {
1055                entry.handler.unmount(instance_id);
1056            })
1057    {
1058        rollback_error = Some(error);
1059    }
1060    if let (Some(resources), Some(checkpoint)) = (resources, checkpoint)
1061        && let Err(error) = resources.rollback(checkpoint)
1062        && rollback_error.is_none()
1063    {
1064        rollback_error = Some(PrimitiveError::Resource(error));
1065    }
1066    rollback_error.unwrap_or(original)
1067}
1068
1069fn primitive_is_retained(
1070    inner: &PrimitiveRegistryInner,
1071    primitive: &PrimitiveId,
1072) -> Result<bool, PrimitiveError> {
1073    let descriptor = &inner
1074        .entries
1075        .get(primitive)
1076        .ok_or_else(|| PrimitiveError::Unknown(primitive.clone()))?
1077        .descriptor;
1078    Ok(descriptor.lifecycle || !descriptor.state.is_empty())
1079}
1080
1081fn collect_primitive_instances(tree: &crate::RetainedUiTree) -> BTreeSet<PrimitiveInstanceId> {
1082    tree.nodes()
1083        .filter_map(|node| {
1084            Some(PrimitiveInstanceId {
1085                primitive: node.primitive()?.clone(),
1086                key: node.key()?.to_owned(),
1087                node: node.id(),
1088            })
1089        })
1090        .collect()
1091}
1092
1093fn guard_primitive_panic<T>(
1094    primitive: &PrimitiveId,
1095    phase: &'static str,
1096    operation: impl FnOnce() -> T,
1097) -> Result<T, PrimitiveError> {
1098    catch_unwind(AssertUnwindSafe(operation)).map_err(|_| PrimitiveError::Panic {
1099        primitive: primitive.clone(),
1100        phase,
1101    })
1102}
1103
1104#[derive(gpui::IntoElement)]
1105struct RegisteredPrimitiveElement {
1106    registry: PrimitiveRegistry,
1107    node: PrimitiveNode,
1108    retained_id: Option<crate::NodeId>,
1109    fallback: Option<UiNode>,
1110    dispatcher: Option<NodeEventDispatcher>,
1111    theme: PrimitiveTheme,
1112}
1113
1114impl RenderOnce for RegisteredPrimitiveElement {
1115    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1116        let registry = self.registry;
1117        let callbacks = self
1118            .node
1119            .props
1120            .iter()
1121            .filter_map(|(name, value)| {
1122                name.strip_prefix("on_").and_then(|event| match value {
1123                    PrimitiveValue::Callback(callback) => {
1124                        Some((event.to_owned(), callback.clone()))
1125                    }
1126                    _ => None,
1127                })
1128            })
1129            .collect();
1130        let events = PrimitiveEventEmitter {
1131            registry: Rc::downgrade(&registry.inner),
1132            primitive: self.node.primitive.clone(),
1133            callbacks,
1134            dispatcher: self.dispatcher,
1135        };
1136        match registry.render_instance(
1137            self.node,
1138            self.retained_id,
1139            &events,
1140            &self.theme,
1141            window,
1142            cx,
1143        ) {
1144            Ok(element) => element,
1145            Err(error) => self.fallback.map_or_else(
1146                || {
1147                    div()
1148                        .child(format!("Custom primitive error: {error}"))
1149                        .into_any_element()
1150                },
1151                |fallback| {
1152                    crate::GpuiNodeRenderer::render_with_primitives(
1153                        &fallback,
1154                        &crate::LiteralColorResolver,
1155                        &crate::InteractionState::default(),
1156                        &registry,
1157                    )
1158                },
1159            ),
1160        }
1161    }
1162}
1163
1164fn validate_descriptor(descriptor: &PrimitiveDescriptor) -> Result<(), PrimitiveError> {
1165    if !is_pascal_case(&descriptor.export) {
1166        return Err(PrimitiveError::InvalidExport(descriptor.export.clone()));
1167    }
1168    for (name, field) in &descriptor.props {
1169        if !is_identifier(name) {
1170            return Err(PrimitiveError::InvalidPropName(name.clone()));
1171        }
1172        field
1173            .schema
1174            .validate_definition()
1175            .map_err(|source| PrimitiveError::InvalidSchema {
1176                location: format!("prop `{name}`"),
1177                source,
1178            })?;
1179        if let Some(default) = &field.default {
1180            field.schema.validate_ui_value(default).map_err(|source| {
1181                PrimitiveError::InvalidDefault {
1182                    prop: name.clone(),
1183                    source,
1184                }
1185            })?;
1186        }
1187    }
1188    for (name, event) in &descriptor.events {
1189        event
1190            .payload
1191            .validate_definition()
1192            .map_err(|source| PrimitiveError::InvalidSchema {
1193                location: format!("event `{name}`"),
1194                source,
1195            })?;
1196        let callback = format!("on_{name}");
1197        if !descriptor
1198            .props
1199            .get(&callback)
1200            .is_some_and(|field| schema_accepts_callback(&field.schema))
1201        {
1202            return Err(PrimitiveError::MissingEventCallback {
1203                event: name.clone(),
1204                prop: callback,
1205            });
1206        }
1207    }
1208    ComponentStateSchema::new(descriptor.state.fields().clone())
1209        .map_err(|source| PrimitiveError::InvalidStateSchema(source.to_string()))?;
1210    Ok(())
1211}
1212
1213fn schema_accepts_callback(schema: &ValueSchema) -> bool {
1214    matches!(schema, ValueSchema::Callback)
1215        || matches!(schema, ValueSchema::Optional { value } if schema_accepts_callback(value))
1216        || matches!(schema, ValueSchema::OneOf { variants } if variants.iter().any(schema_accepts_callback))
1217}
1218
1219fn convert_props(
1220    schema: &BTreeMap<String, ObjectField>,
1221    values: &Map,
1222    generation: ScriptGeneration,
1223) -> Result<PrimitiveProps, PrimitiveError> {
1224    let mut converted = BTreeMap::new();
1225    for (name, field) in schema {
1226        let value = values
1227            .get(name.as_str())
1228            .cloned()
1229            .or_else(|| field.default.clone().map(UiValue::into_dynamic));
1230        if let Some(value) = value {
1231            converted.insert(
1232                name.clone(),
1233                convert_prop(&field.schema, value, generation).map_err(|source| {
1234                    PrimitiveError::PropConversion {
1235                        prop: name.clone(),
1236                        source,
1237                    }
1238                })?,
1239            );
1240        }
1241    }
1242    Ok(PrimitiveProps(converted))
1243}
1244
1245fn convert_prop(
1246    schema: &ValueSchema,
1247    value: Dynamic,
1248    generation: ScriptGeneration,
1249) -> Result<PrimitiveValue, PrimitivePropConversionError> {
1250    match schema {
1251        ValueSchema::Optional { value: inner } if value.is_unit() => {
1252            Ok(PrimitiveValue::Data(UiValue::Null))
1253        }
1254        ValueSchema::Optional { value: inner } => convert_prop(inner, value, generation),
1255        ValueSchema::OneOf { variants } => {
1256            let branch = variants
1257                .iter()
1258                .find(|variant| variant.validate(&value).is_ok())
1259                .expect("validated primitive one_of prop matches one branch");
1260            convert_prop(branch, value, generation)
1261        }
1262        ValueSchema::Node => Ok(PrimitiveValue::Node(Box::new(value.cast::<UiNode>()))),
1263        ValueSchema::Callback if value.is::<FnPtr>() => {
1264            Ok(PrimitiveValue::Callback(UiEventHandler::Script(
1265                ScriptCallback::try_from_fn_ptr(value.cast::<FnPtr>(), generation)?,
1266            )))
1267        }
1268        ValueSchema::Callback => Ok(PrimitiveValue::Callback(UiEventHandler::Native(
1269            value.cast::<crate::NativeHandlerRef>(),
1270        ))),
1271        ValueSchema::Array { items, .. } if matches!(items.as_ref(), ValueSchema::Node) => {
1272            Ok(PrimitiveValue::Nodes(
1273                value
1274                    .cast::<Array>()
1275                    .into_iter()
1276                    .map(Dynamic::cast::<UiNode>)
1277                    .collect(),
1278            ))
1279        }
1280        ValueSchema::Style => Ok(PrimitiveValue::Style(Box::new(value.cast::<Style>()))),
1281        ValueSchema::Length => Ok(PrimitiveValue::Length(value.cast::<Length>())),
1282        ValueSchema::Asset => Ok(PrimitiveValue::Asset(value.cast::<AssetId>())),
1283        ValueSchema::Signal => Ok(PrimitiveValue::Signal(value.cast::<crate::NativeSignal>())),
1284        ValueSchema::Ref => Ok(PrimitiveValue::Ref(value.cast::<crate::ElementRef>())),
1285        ValueSchema::Document => Ok(PrimitiveValue::Document(
1286            value.cast::<crate::NativeTextDocument>(),
1287        )),
1288        _ => UiValue::from_dynamic(value)
1289            .map(PrimitiveValue::Data)
1290            .map_err(Into::into),
1291    }
1292}
1293
1294#[derive(Debug, Error)]
1295pub enum PrimitivePropConversionError {
1296    #[error(transparent)]
1297    Value(#[from] UiValueError),
1298    #[error(transparent)]
1299    Callback(#[from] crate::ScriptCallbackDefinitionError),
1300}
1301
1302#[derive(Debug, Error)]
1303pub enum PrimitiveError {
1304    #[error("primitive ID `{0}` must be `namespace.snake_case_name`")]
1305    InvalidId(String),
1306    #[error("primitive export `{0}` must be PascalCase")]
1307    InvalidExport(String),
1308    #[error("primitive prop `{0}` must be `snake_case`")]
1309    InvalidPropName(String),
1310    #[error("invalid schema definition for primitive {location}: {source}")]
1311    InvalidSchema {
1312        location: String,
1313        source: SchemaDefinitionError,
1314    },
1315    #[error("invalid primitive state schema: {0}")]
1316    InvalidStateSchema(String),
1317    #[error("primitive registry is already borrowed during rendering")]
1318    Borrowed,
1319    #[error("primitive `{0:?}` is already registered")]
1320    Duplicate(PrimitiveId),
1321    #[error("primitive export `{namespace}::{export}` is already registered")]
1322    DuplicateExport { namespace: String, export: String },
1323    #[error("primitive `{0:?}` is not registered")]
1324    Unknown(PrimitiveId),
1325    #[error("primitive `{0:?}` requires a stable key")]
1326    MissingKey(PrimitiveId),
1327    #[error("primitive `{0:?}` requires a retained NodeId renderer")]
1328    MissingRetainedIdentity(PrimitiveId),
1329    #[error("primitive event emitter outlived its registry")]
1330    RegistryReleased,
1331    #[error("props for primitive `{primitive:?}` are invalid: {source}")]
1332    InvalidProps {
1333        primitive: PrimitiveId,
1334        source: SchemaValidationError,
1335    },
1336    #[error("default for primitive prop `{prop}` is invalid: {source}")]
1337    InvalidDefault {
1338        prop: String,
1339        source: SchemaValidationError,
1340    },
1341    #[error("primitive prop `{prop}` cannot cross the runtime boundary: {source}")]
1342    PropConversion {
1343        prop: String,
1344        source: PrimitivePropConversionError,
1345    },
1346    #[error("primitive event `{event}` requires callback prop `{prop}`")]
1347    MissingEventCallback { event: String, prop: String },
1348    #[error("primitive `{primitive:?}` does not declare event `{event}`")]
1349    UnknownEvent {
1350        primitive: PrimitiveId,
1351        event: String,
1352    },
1353    #[error("primitive `{primitive:?}` event `{event}` is invalid: {source}")]
1354    InvalidEvent {
1355        primitive: PrimitiveId,
1356        event: String,
1357        source: SchemaValidationError,
1358    },
1359    #[error("primitive `{primitive:?}` handler failed: {message}")]
1360    Handler {
1361        primitive: PrimitiveId,
1362        message: String,
1363    },
1364    #[error("primitive `{primitive:?}` panicked during {phase}")]
1365    Panic {
1366        primitive: PrimitiveId,
1367        phase: &'static str,
1368    },
1369    #[error(transparent)]
1370    Resource(#[from] PrimitiveResourceError),
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    use super::*;
1376    use crate::{ObjectField, StateField};
1377    use std::cell::Cell;
1378
1379    struct TestHandler;
1380
1381    struct TestTheme;
1382
1383    impl ColorResolver for TestTheme {
1384        fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
1385            matches!(color, ColorValue::Token(token) if token == "accent")
1386                .then(|| Rgba8::from_rgba_hex(0x1234_56ff))
1387        }
1388
1389        fn resolve_length(&self, length: Length) -> Option<Length> {
1390            (length == Length::ThemeSpacing(SpacingToken::Sm)).then_some(Length::Pixels(6.0))
1391        }
1392
1393        fn resolve_typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
1394            (role == "body").then(|| crate::ResolvedTypography {
1395                family: Some("JetBrains Mono".to_owned()),
1396                fallbacks: vec!["PingFang SC".to_owned()],
1397                size: Length::Pixels(12.0),
1398                line_height: Length::Pixels(16.0),
1399                weight: 400,
1400            })
1401        }
1402    }
1403
1404    impl PrimitiveHandler for TestHandler {
1405        fn render(
1406            &mut self,
1407            _: &PrimitiveInstance,
1408            _: &PrimitiveEventEmitter,
1409            _: &PrimitiveTheme,
1410            _: &mut Window,
1411            _: &mut App,
1412        ) -> Result<AnyElement, String> {
1413            Ok(div().into_any_element())
1414        }
1415    }
1416
1417    #[test]
1418    fn primitive_theme_exposes_only_resolved_semantic_snapshot() {
1419        let theme = PrimitiveTheme::capture(&TestTheme);
1420        assert_eq!(
1421            theme.color("accent"),
1422            Some(Rgba8::from_rgba_hex(0x1234_56ff))
1423        );
1424        assert_eq!(theme.color("unknown"), None);
1425        assert_eq!(
1426            theme.resolve_color(&ColorValue::Literal(Rgba8::from_rgba_hex(0xaabb_ccdd))),
1427            Some(Rgba8::from_rgba_hex(0xaabb_ccdd))
1428        );
1429        assert_eq!(
1430            theme.resolve_length(Length::ThemeSpacing(SpacingToken::Sm)),
1431            Some(Length::Pixels(6.0))
1432        );
1433        assert_eq!(
1434            theme.typography("body").unwrap().family.as_deref(),
1435            Some("JetBrains Mono")
1436        );
1437    }
1438
1439    fn descriptor() -> PrimitiveDescriptor {
1440        PrimitiveDescriptor {
1441            id: PrimitiveId::parse("my_app.code_editor").unwrap(),
1442            export: "CodeEditor".to_owned(),
1443            props: BTreeMap::from([
1444                (
1445                    "value".to_owned(),
1446                    ObjectField::required(ValueSchema::string()),
1447                ),
1448                (
1449                    "on_change".to_owned(),
1450                    ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
1451                ),
1452            ]),
1453            events: BTreeMap::from([(
1454                "change".to_owned(),
1455                EventSchema {
1456                    payload: ValueSchema::string(),
1457                },
1458            )]),
1459            state: ComponentStateSchema::new(BTreeMap::from([(
1460                "selection".to_owned(),
1461                StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
1462            )]))
1463            .unwrap(),
1464            lifecycle: true,
1465        }
1466    }
1467
1468    #[test]
1469    fn custom_primitive_props_and_keys_are_validated() {
1470        let registry = PrimitiveRegistry::new();
1471        let descriptor = descriptor();
1472        let id = descriptor.id.clone();
1473        registry.register(descriptor, TestHandler).unwrap();
1474        assert!(matches!(
1475            registry.create_node(
1476                &id,
1477                None,
1478                &Map::from_iter([("value".into(), Dynamic::from("source"))]),
1479                ScriptGeneration::initial(),
1480            ),
1481            Err(PrimitiveError::MissingKey(_))
1482        ));
1483        registry
1484            .create_node(
1485                &id,
1486                Some("editor".to_owned()),
1487                &Map::from_iter([("value".into(), Dynamic::from("source"))]),
1488                ScriptGeneration::initial(),
1489            )
1490            .unwrap();
1491    }
1492
1493    #[test]
1494    fn custom_primitive_events_are_normalized() {
1495        let registry = PrimitiveRegistry::new();
1496        let descriptor = descriptor();
1497        let id = descriptor.id.clone();
1498        registry.register(descriptor, TestHandler).unwrap();
1499        assert_eq!(
1500            registry
1501                .normalize_event(&id, "change", UiValue::String("new".to_owned()))
1502                .unwrap(),
1503            UiValue::String("new".to_owned())
1504        );
1505        assert!(matches!(
1506            registry.normalize_event(&id, "change", UiValue::Bool(true)),
1507            Err(PrimitiveError::InvalidEvent { .. })
1508        ));
1509    }
1510
1511    #[test]
1512    fn native_panics_are_converted_to_primitive_errors() {
1513        let id = PrimitiveId::parse("my_app.crash").unwrap();
1514        assert!(matches!(
1515            guard_primitive_panic(&id, "render", || panic!("boom")),
1516            Err(PrimitiveError::Panic {
1517                phase: "render",
1518                ..
1519            })
1520        ));
1521    }
1522
1523    #[test]
1524    fn primitive_resource_scope_rolls_back_and_continues_after_cleanup_panic() {
1525        let scope = PrimitiveResourceScope::new();
1526        let retained = Rc::new(Cell::new(0));
1527        let retained_cleanup = Rc::clone(&retained);
1528        scope
1529            .own("retained", move || retained_cleanup.set(1))
1530            .unwrap();
1531        let checkpoint = scope.checkpoint().unwrap();
1532        let order = Rc::new(RefCell::new(Vec::new()));
1533        let first = Rc::clone(&order);
1534        scope
1535            .own("first", move || first.borrow_mut().push(1))
1536            .unwrap();
1537        scope.own("panic", || panic!("cleanup failed")).unwrap();
1538        let last = Rc::clone(&order);
1539        scope
1540            .own("last", move || last.borrow_mut().push(3))
1541            .unwrap();
1542
1543        assert!(matches!(
1544            scope.rollback(checkpoint),
1545            Err(PrimitiveResourceError::CleanupPanic { ref label }) if label == "panic"
1546        ));
1547        assert_eq!(*order.borrow(), vec![3, 1]);
1548        assert_eq!(scope.active_count(), 1);
1549        scope.close().unwrap();
1550        assert_eq!(retained.get(), 1);
1551    }
1552
1553    #[test]
1554    fn successful_tree_cleanup_unmounts_removed_keyed_instances() {
1555        struct UnmountCounter(Rc<Cell<usize>>);
1556        impl PrimitiveHandler for UnmountCounter {
1557            fn render(
1558                &mut self,
1559                _: &PrimitiveInstance,
1560                _: &PrimitiveEventEmitter,
1561                _: &PrimitiveTheme,
1562                _: &mut Window,
1563                _: &mut App,
1564            ) -> Result<AnyElement, String> {
1565                Ok(div().into_any_element())
1566            }
1567
1568            fn unmount(&mut self, _: &PrimitiveInstanceId) {
1569                self.0.set(self.0.get() + 1);
1570            }
1571        }
1572
1573        let registry = PrimitiveRegistry::new();
1574        let descriptor = descriptor();
1575        let node = PrimitiveNode {
1576            primitive: descriptor.id.clone(),
1577            key: Some("editor".to_owned()),
1578            props: PrimitiveProps::new(),
1579        };
1580        let mut tree = crate::RetainedUiTree::new();
1581        tree.reconcile(UiNode::custom(node.clone())).unwrap();
1582        let instance = collect_primitive_instances(&tree)
1583            .into_iter()
1584            .next()
1585            .unwrap();
1586        let unmounted = Rc::new(Cell::new(0));
1587        let cleaned = Rc::new(Cell::new(0));
1588        let resources = PrimitiveResourceScope::new();
1589        let cleanup = Rc::clone(&cleaned);
1590        resources
1591            .own("watcher", move || cleanup.set(cleanup.get() + 1))
1592            .unwrap();
1593        registry
1594            .register(descriptor, UnmountCounter(Rc::clone(&unmounted)))
1595            .unwrap();
1596        registry.inner.borrow_mut().mounted.insert(
1597            instance.clone(),
1598            PrimitiveInstance {
1599                id: Some(instance.clone()),
1600                node,
1601                resources: Some(resources),
1602            },
1603        );
1604        tree.reconcile(UiNode::text("removed")).unwrap();
1605        registry.retain_tree(&tree).unwrap();
1606        assert_eq!(unmounted.get(), 1);
1607        assert_eq!(cleaned.get(), 1);
1608    }
1609
1610    #[test]
1611    fn primitive_identity_uses_retained_node_not_component_local_key() {
1612        let primitive = PrimitiveId::parse("my_app.editor").unwrap();
1613        let branch = |branch: &str| {
1614            UiNode::box_node(vec![UiNode::custom(PrimitiveNode {
1615                primitive: primitive.clone(),
1616                key: Some("editor".to_owned()),
1617                props: PrimitiveProps::new().with(
1618                    "branch",
1619                    PrimitiveValue::Data(UiValue::String(branch.to_owned())),
1620                ),
1621            })])
1622            .with_key(branch)
1623        };
1624        let mut tree = crate::RetainedUiTree::new();
1625        tree.reconcile(UiNode::box_node(vec![branch("left"), branch("right")]))
1626            .unwrap();
1627        let before = collect_primitive_instances(&tree);
1628        assert_eq!(before.len(), 2);
1629        assert!(before.iter().all(|instance| instance.key() == "editor"));
1630        assert_eq!(
1631            before
1632                .iter()
1633                .map(PrimitiveInstanceId::node)
1634                .collect::<BTreeSet<_>>()
1635                .len(),
1636            2
1637        );
1638
1639        tree.reconcile(UiNode::box_node(vec![branch("right"), branch("left")]))
1640            .unwrap();
1641        assert_eq!(collect_primitive_instances(&tree), before);
1642    }
1643
1644    #[test]
1645    fn primitive_event_emitter_holds_only_a_weak_registry_reference() {
1646        let registry = PrimitiveRegistry::new();
1647        let weak = Rc::downgrade(&registry.inner);
1648        let emitter = PrimitiveEventEmitter {
1649            registry: Rc::downgrade(&registry.inner),
1650            primitive: PrimitiveId::parse("my_app.editor").unwrap(),
1651            callbacks: BTreeMap::new(),
1652            dispatcher: None,
1653        };
1654        assert_eq!(Rc::strong_count(&registry.inner), 1);
1655        drop(registry);
1656        assert!(weak.upgrade().is_none());
1657        drop(emitter);
1658    }
1659}