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