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 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#[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 #[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 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 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 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 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 #[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 fn effect_cost(&self, _instance: &PrimitiveInstance) -> usize {
759 1
760 }
761
762 fn mount(&mut self, _instance: &PrimitiveInstance) -> Result<(), String> {
768 Ok(())
769 }
770
771 fn update(
777 &mut self,
778 _previous: &PrimitiveInstance,
779 _next: &PrimitiveInstance,
780 ) -> Result<(), String> {
781 Ok(())
782 }
783
784 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 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 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 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 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 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 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(®istry.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 ®istry,
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(®istry.inner);
1823 let emitter = PrimitiveEventEmitter {
1824 registry: Rc::downgrade(®istry.inner),
1825 primitive: PrimitiveId::parse("my_app.editor").unwrap(),
1826 callbacks: BTreeMap::new(),
1827 dispatcher: None,
1828 };
1829 assert_eq!(Rc::strong_count(®istry.inner), 1);
1830 drop(registry);
1831 assert!(weak.upgrade().is_none());
1832 drop(emitter);
1833 }
1834}