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 #[cfg(feature = "charts")]
152 ChartData(crate::NativeChartData),
153}
154
155#[derive(Clone, Debug)]
161pub struct PrimitiveTheme {
162 colors: BTreeMap<String, Rgba8>,
163 spacing: BTreeMap<SpacingToken, Length>,
164 radii: BTreeMap<RadiusToken, Length>,
165 typography: BTreeMap<String, crate::ResolvedTypography>,
166 direction: crate::TextDirection,
167 locale: String,
168 number: Option<crate::NumberMetadata>,
169 motion: crate::ThemeMotion,
170 motion_preference: crate::MotionPreference,
171 motion_quality: crate::MotionQuality,
172 clock: crate::RuntimeClock,
173}
174
175pub(crate) const RUNTIME_THEME_COLOR_TOKENS: &[&str] = &[
176 "surface",
177 "surface_raised",
178 "surface_hover",
179 "text_primary",
180 "text_muted",
181 "accent",
182 "accent_hover",
183 "on_accent",
184 "danger",
185 "on_danger",
186 "warning",
187 "on_warning",
188 "success",
189 "on_success",
190 "border",
191 "focus_ring",
192 "selection",
193 "disabled",
194 "syntax.comment",
195 "syntax.string",
196 "syntax.number",
197 "syntax.keyword",
198 "syntax.function",
199 "syntax.type",
200 "syntax.variable",
201 "syntax.constant",
202 "syntax.operator",
203 "syntax.punctuation",
204 "syntax.tag",
205 "syntax.attribute",
206 "document.search_match",
207 "document.search_current",
208 "diff.left_only",
209 "diff.right_only",
210 "diff.modified",
211 "diff.inline_left",
212 "diff.inline_right",
213 "diff.gutter",
214 "diff.fold",
215 "charts.axis",
216 "charts.grid",
217 "charts.tooltip_surface",
218 "charts.tooltip_text",
219 "charts.positive",
220 "charts.negative",
221 "charts.selection",
222 "charts.map_missing",
223 "charts.crosshair",
224 "charts.palette_1",
225 "charts.palette_2",
226 "charts.palette_3",
227 "charts.palette_4",
228 "charts.palette_5",
229 "charts.palette_6",
230 "charts.palette_7",
231 "charts.palette_8",
232 "table.selection",
233];
234
235pub(crate) const RUNTIME_THEME_SPACING_TOKENS: &[SpacingToken] = &[
236 SpacingToken::Xxs,
237 SpacingToken::Xs,
238 SpacingToken::Sm,
239 SpacingToken::Md,
240 SpacingToken::Lg,
241];
242
243pub(crate) const RUNTIME_THEME_RADIUS_TOKENS: &[RadiusToken] =
244 &[RadiusToken::Sm, RadiusToken::Md, RadiusToken::Lg];
245
246impl Default for PrimitiveTheme {
247 fn default() -> Self {
248 Self {
249 colors: BTreeMap::new(),
250 spacing: BTreeMap::new(),
251 radii: BTreeMap::new(),
252 typography: BTreeMap::new(),
253 direction: crate::TextDirection::LeftToRight,
254 locale: "en".to_owned(),
255 number: None,
256 motion: crate::ThemeMotion::default(),
257 motion_preference: crate::MotionPreference::Normal,
258 motion_quality: crate::MotionQuality::High,
259 clock: crate::RuntimeClock::default(),
260 }
261 }
262}
263
264impl PrimitiveTheme {
265 #[cfg(test)]
266 pub(crate) fn capture(colors: &impl ColorResolver) -> Self {
267 Self::capture_with_direction(colors, crate::TextDirection::LeftToRight)
268 }
269
270 #[cfg(test)]
271 pub(crate) fn capture_with_direction(
272 colors: &impl ColorResolver,
273 direction: crate::TextDirection,
274 ) -> Self {
275 Self::capture_with_motion_policy(
276 colors,
277 direction,
278 crate::MotionPreference::Normal,
279 crate::MotionQuality::High,
280 )
281 }
282
283 #[cfg(test)]
284 pub(crate) fn capture_with_motion_policy(
285 colors: &impl ColorResolver,
286 direction: crate::TextDirection,
287 motion_preference: crate::MotionPreference,
288 motion_quality: crate::MotionQuality,
289 ) -> Self {
290 Self::capture_with_environment(
291 colors,
292 direction,
293 "en",
294 None,
295 crate::RuntimeClock::default(),
296 motion_preference,
297 motion_quality,
298 )
299 }
300
301 #[allow(clippy::too_many_lines)]
302 pub(crate) fn capture_with_environment(
303 colors: &impl ColorResolver,
304 direction: crate::TextDirection,
305 locale: &str,
306 number: Option<&crate::NumberMetadata>,
307 clock: crate::RuntimeClock,
308 motion_preference: crate::MotionPreference,
309 motion_quality: crate::MotionQuality,
310 ) -> Self {
311 Self {
312 colors: colors.color_snapshot(),
313 spacing: RUNTIME_THEME_SPACING_TOKENS
314 .iter()
315 .copied()
316 .filter_map(|token| {
317 colors
318 .resolve_length(Length::ThemeSpacing(token))
319 .map(|value| (token, value))
320 })
321 .collect(),
322 radii: RUNTIME_THEME_RADIUS_TOKENS
323 .iter()
324 .copied()
325 .filter_map(|token| {
326 colors
327 .resolve_length(Length::ThemeRadius(token))
328 .map(|value| (token, value))
329 })
330 .collect(),
331 typography: crate::REQUIRED_TYPOGRAPHY
332 .iter()
333 .filter_map(|role| {
334 colors
335 .resolve_typography(role)
336 .map(|value| ((*role).to_owned(), value))
337 })
338 .collect(),
339 direction,
340 locale: locale.to_owned(),
341 number: number.cloned(),
342 motion: colors.resolve_motion(),
343 motion_preference,
344 motion_quality,
345 clock,
346 }
347 }
348
349 #[must_use]
350 pub fn color(&self, token: &str) -> Option<Rgba8> {
351 self.colors.get(token).copied()
352 }
353
354 #[must_use]
355 pub fn resolve_color(&self, value: &ColorValue) -> Option<Rgba8> {
356 match value {
357 ColorValue::Literal(value) => Some(*value),
358 ColorValue::Token(token) => self.color(token),
359 }
360 }
361
362 #[must_use]
363 pub fn resolve_length(&self, value: Length) -> Option<Length> {
364 match value {
365 Length::ThemeSpacing(token) => self.spacing.get(&token).copied(),
366 Length::ThemeRadius(token) => self.radii.get(&token).copied(),
367 Length::Pixels(_) | Length::Rems(_) | Length::Relative(_) => Some(value),
368 }
369 }
370
371 #[must_use]
372 pub const fn direction(&self) -> crate::TextDirection {
373 self.direction
374 }
375
376 #[must_use]
377 pub fn locale(&self) -> &str {
378 &self.locale
379 }
380
381 #[must_use]
382 pub const fn number_metadata(&self) -> Option<&crate::NumberMetadata> {
383 self.number.as_ref()
384 }
385
386 #[must_use]
387 pub const fn motion_preference(&self) -> crate::MotionPreference {
388 self.motion_preference
389 }
390
391 #[must_use]
392 pub const fn motion_quality(&self) -> crate::MotionQuality {
393 self.motion_quality
394 }
395
396 #[must_use]
397 pub fn now(&self) -> std::time::Instant {
398 self.clock.now()
399 }
400
401 #[must_use]
402 pub const fn motion(&self) -> &crate::ThemeMotion {
403 &self.motion
404 }
405
406 #[must_use]
407 pub fn typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
408 self.typography.get(role).cloned()
409 }
410}
411
412impl ColorResolver for PrimitiveTheme {
413 fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
414 self.resolve_color(color)
415 }
416
417 fn resolve_length(&self, length: Length) -> Option<Length> {
418 PrimitiveTheme::resolve_length(self, length)
419 }
420
421 fn color_snapshot(&self) -> BTreeMap<String, Rgba8> {
422 self.colors.clone()
423 }
424
425 fn resolve_typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
426 self.typography(role)
427 }
428
429 fn resolve_motion(&self) -> crate::ThemeMotion {
430 self.motion.clone()
431 }
432}
433
434#[derive(Clone, Debug, Default, PartialEq)]
435pub struct PrimitiveProps(BTreeMap<String, PrimitiveValue>);
436
437impl PrimitiveProps {
438 #[must_use]
439 pub fn new() -> Self {
440 Self::default()
441 }
442
443 #[must_use]
444 pub fn get(&self, name: &str) -> Option<&PrimitiveValue> {
445 self.0.get(name)
446 }
447
448 pub fn insert(
449 &mut self,
450 name: impl Into<String>,
451 value: PrimitiveValue,
452 ) -> Option<PrimitiveValue> {
453 self.0.insert(name.into(), value)
454 }
455
456 #[must_use]
457 pub fn with(mut self, name: impl Into<String>, value: PrimitiveValue) -> Self {
458 self.insert(name, value);
459 self
460 }
461
462 pub fn iter(&self) -> impl Iterator<Item = (&str, &PrimitiveValue)> {
463 self.0.iter().map(|(name, value)| (name.as_str(), value))
464 }
465
466 pub(crate) fn iter_mut(&mut self) -> impl Iterator<Item = (&str, &mut PrimitiveValue)> {
467 self.0
468 .iter_mut()
469 .map(|(name, value)| (name.as_str(), value))
470 }
471
472 pub(crate) fn bind_component_scope(
473 &mut self,
474 component: &crate::ComponentInstancePath,
475 incarnation: crate::ComponentIncarnation,
476 events: &BTreeMap<String, EventSchema>,
477 native_context: Option<&crate::invocation::ScriptInvocationContext>,
478 ) {
479 for value in self.0.values_mut() {
480 match value {
481 PrimitiveValue::Callback(callback) => {
482 if let Some(callback) = callback.as_script_mut() {
483 callback.bind_component_scope_if_unset(
484 component,
485 incarnation,
486 events.clone(),
487 );
488 if let Some(context) = native_context {
489 callback.bind_native_context_if_unset(context.clone());
490 }
491 }
492 }
493 PrimitiveValue::Node(node) => {
494 node.bind_component_scope(component, incarnation, events, native_context);
495 }
496 PrimitiveValue::Nodes(nodes) => {
497 for node in nodes {
498 node.bind_component_scope(component, incarnation, events, native_context);
499 }
500 }
501 PrimitiveValue::Data(_)
502 | PrimitiveValue::Style(_)
503 | PrimitiveValue::Length(_)
504 | PrimitiveValue::Asset(_)
505 | PrimitiveValue::Signal(_)
506 | PrimitiveValue::Ref(_)
507 | PrimitiveValue::Document(_) => {}
508 #[cfg(feature = "charts")]
509 PrimitiveValue::ChartData(_) => {}
510 }
511 }
512 }
513}
514
515#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
516pub struct PrimitiveInstanceId {
517 primitive: PrimitiveId,
518 key: String,
519 node: crate::NodeId,
520}
521
522impl PrimitiveInstanceId {
523 pub(crate) fn new(primitive: PrimitiveId, key: String, node: crate::NodeId) -> Self {
524 Self {
525 primitive,
526 key,
527 node,
528 }
529 }
530
531 #[must_use]
532 pub const fn primitive(&self) -> &PrimitiveId {
533 &self.primitive
534 }
535
536 #[must_use]
537 pub fn key(&self) -> &str {
538 &self.key
539 }
540
541 #[must_use]
542 pub const fn node(&self) -> crate::NodeId {
543 self.node
544 }
545}
546
547#[derive(Clone, Debug, PartialEq)]
548pub struct PrimitiveNode {
549 pub primitive: PrimitiveId,
550 pub key: Option<String>,
551 pub props: PrimitiveProps,
552}
553
554#[derive(Clone, Debug)]
555pub struct PrimitiveInstance {
556 pub id: Option<PrimitiveInstanceId>,
557 pub node: PrimitiveNode,
558 resources: Option<PrimitiveResourceScope>,
559 focus_handle: Option<gpui::FocusHandle>,
560}
561
562impl PrimitiveInstance {
563 #[must_use]
567 pub const fn resources(&self) -> Option<&PrimitiveResourceScope> {
568 self.resources.as_ref()
569 }
570
571 #[must_use]
577 pub const fn focus_handle(&self) -> Option<&gpui::FocusHandle> {
578 self.focus_handle.as_ref()
579 }
580}
581
582#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
583pub struct PrimitiveResourceHandle(u64);
584
585struct PrimitiveResourceEntry {
586 label: String,
587 cleanup: Option<Box<dyn FnOnce()>>,
588}
589
590#[derive(Default)]
591struct PrimitiveResourceState {
592 next_id: u64,
593 entries: BTreeMap<u64, PrimitiveResourceEntry>,
594}
595
596impl Drop for PrimitiveResourceState {
597 fn drop(&mut self) {
598 let entries = std::mem::take(&mut self.entries);
599 for entry in entries.into_values().rev() {
600 let _ = run_resource_cleanup(entry);
601 }
602 }
603}
604
605#[derive(Clone, Default)]
606pub struct PrimitiveResourceScope {
607 inner: Rc<RefCell<PrimitiveResourceState>>,
608}
609
610impl fmt::Debug for PrimitiveResourceScope {
611 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
612 match self.inner.try_borrow() {
613 Ok(state) => formatter
614 .debug_struct("PrimitiveResourceScope")
615 .field(
616 "active",
617 &state
618 .entries
619 .values()
620 .map(|entry| entry.label.as_str())
621 .collect::<Vec<_>>(),
622 )
623 .finish(),
624 Err(_) => formatter.write_str("PrimitiveResourceScope(<borrowed>)"),
625 }
626 }
627}
628
629impl PrimitiveResourceScope {
630 #[must_use]
631 pub fn new() -> Self {
632 Self::default()
633 }
634
635 pub fn own(
642 &self,
643 label: impl Into<String>,
644 cleanup: impl FnOnce() + 'static,
645 ) -> Result<PrimitiveResourceHandle, PrimitiveResourceError> {
646 let label = label.into();
647 if label.is_empty()
648 || label.len() > 128
649 || !label.chars().all(|character| {
650 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
651 })
652 {
653 return Err(PrimitiveResourceError::InvalidLabel(label));
654 }
655 let mut state = self
656 .inner
657 .try_borrow_mut()
658 .map_err(|_| PrimitiveResourceError::Borrowed)?;
659 let id = state.next_id.max(1);
660 state.next_id = id
661 .checked_add(1)
662 .ok_or(PrimitiveResourceError::IdExhausted)?;
663 state.entries.insert(
664 id,
665 PrimitiveResourceEntry {
666 label,
667 cleanup: Some(Box::new(cleanup)),
668 },
669 );
670 Ok(PrimitiveResourceHandle(id))
671 }
672
673 pub fn cancel(&self, handle: &PrimitiveResourceHandle) -> Result<bool, PrimitiveResourceError> {
679 let entry = self
680 .inner
681 .try_borrow_mut()
682 .map_err(|_| PrimitiveResourceError::Borrowed)?
683 .entries
684 .remove(&handle.0);
685 let Some(entry) = entry else {
686 return Ok(false);
687 };
688 run_resource_cleanup(entry)?;
689 Ok(true)
690 }
691
692 #[must_use]
693 pub fn active_count(&self) -> usize {
694 self.inner.borrow().entries.len()
695 }
696
697 fn checkpoint(&self) -> Result<u64, PrimitiveResourceError> {
698 self.inner
699 .try_borrow()
700 .map(|state| state.next_id.max(1))
701 .map_err(|_| PrimitiveResourceError::Borrowed)
702 }
703
704 fn rollback(&self, checkpoint: u64) -> Result<(), PrimitiveResourceError> {
705 self.cleanup_where(|id| id >= checkpoint)
706 }
707
708 fn close(&self) -> Result<(), PrimitiveResourceError> {
709 self.cleanup_where(|_| true)
710 }
711
712 fn cleanup_where(&self, predicate: impl Fn(u64) -> bool) -> Result<(), PrimitiveResourceError> {
713 let mut entries = {
714 let mut state = self
715 .inner
716 .try_borrow_mut()
717 .map_err(|_| PrimitiveResourceError::Borrowed)?;
718 let ids = state
719 .entries
720 .keys()
721 .copied()
722 .filter(|id| predicate(*id))
723 .collect::<Vec<_>>();
724 ids.into_iter()
725 .rev()
726 .filter_map(|id| state.entries.remove(&id))
727 .collect::<Vec<_>>()
728 };
729 let mut first_error = None;
730 for entry in entries.drain(..) {
731 if let Err(error) = run_resource_cleanup(entry)
732 && first_error.is_none()
733 {
734 first_error = Some(error);
735 }
736 }
737 first_error.map_or(Ok(()), Err)
738 }
739}
740
741fn run_resource_cleanup(mut entry: PrimitiveResourceEntry) -> Result<(), PrimitiveResourceError> {
742 let label = entry.label;
743 let Some(cleanup) = entry.cleanup.take() else {
744 return Ok(());
745 };
746 catch_unwind(AssertUnwindSafe(cleanup))
747 .map_err(|_| PrimitiveResourceError::CleanupPanic { label })
748}
749
750#[derive(Clone, Debug, Error, Eq, PartialEq)]
751pub enum PrimitiveResourceError {
752 #[error("primitive resource label `{0}` must be 1-128 safe ASCII characters")]
753 InvalidLabel(String),
754 #[error("primitive resource scope is already borrowed")]
755 Borrowed,
756 #[error("primitive resource scope exhausted its handle identity space")]
757 IdExhausted,
758 #[error("primitive resource cleanup `{label}` panicked")]
759 CleanupPanic { label: String },
760}
761
762#[derive(Clone)]
763pub struct PrimitiveEventEmitter {
764 registry: Weak<RefCell<PrimitiveRegistryInner>>,
765 primitive: PrimitiveId,
766 callbacks: BTreeMap<String, UiEventHandler>,
767 dispatcher: Option<NodeEventDispatcher>,
768}
769
770impl PrimitiveEventEmitter {
771 pub fn emit(
778 &self,
779 event: &str,
780 payload: UiValue,
781 window: &mut Window,
782 cx: &mut App,
783 ) -> Result<(), PrimitiveError> {
784 let registry = PrimitiveRegistry {
785 inner: self
786 .registry
787 .upgrade()
788 .ok_or(PrimitiveError::RegistryReleased)?,
789 };
790 let payload = registry.normalize_event(&self.primitive, event, payload)?;
791 if let Some(handler) = self.callbacks.get(event) {
792 match handler {
793 UiEventHandler::Script(callback) => {
794 if let Some(dispatcher) = self.dispatcher.as_ref() {
795 dispatcher.dispatch(callback.clone(), payload, None, window, cx);
796 }
797 }
798 UiEventHandler::Host(callback) => {
799 callback.invoke(payload, window, cx);
800 }
801 UiEventHandler::Native(reference) => {
802 if let Some(dispatcher) = self.dispatcher.as_ref() {
803 dispatcher.dispatch_native(
804 reference.clone(),
805 event.to_owned(),
806 payload,
807 None,
808 window,
809 cx,
810 );
811 }
812 }
813 }
814 }
815 Ok(())
816 }
817
818 pub fn write_signal(
825 &self,
826 signal: &crate::NativeSignal,
827 value: crate::SignalValue,
828 cx: &mut App,
829 ) -> Result<bool, crate::SignalError> {
830 self.dispatcher.as_ref().map_or_else(
831 || Err(crate::SignalError::Stale(signal.id().clone())),
832 |dispatcher| dispatcher.write_signal(signal.clone(), value, cx),
833 )
834 }
835
836 #[must_use]
838 pub fn element_bounds(
839 &self,
840 reference: &crate::ElementRef,
841 cx: &App,
842 ) -> Option<crate::GeometryBounds> {
843 self.dispatcher
844 .as_ref()
845 .and_then(|dispatcher| dispatcher.element_bounds(reference, cx))
846 }
847}
848
849pub trait PrimitiveHandler {
850 fn effect_cost(&self, _instance: &PrimitiveInstance) -> usize {
853 1
854 }
855
856 fn accessibility(
860 &self,
861 _instance: &PrimitiveInstanceId,
862 _cx: &App,
863 ) -> Option<PrimitiveAccessibilityProjection> {
864 None
865 }
866
867 fn accessibility_actions(
872 &self,
873 _instance: &PrimitiveInstanceId,
874 ) -> Vec<gpui::AccessibleAction> {
875 Vec::new()
876 }
877
878 fn uses_primary_focus(&self) -> bool {
883 false
884 }
885
886 fn perform_accessibility_action(
893 &mut self,
894 _instance: &PrimitiveInstanceId,
895 _action: gpui::AccessibleAction,
896 _data: Option<&gpui::accesskit::ActionData>,
897 _window: &mut Window,
898 _cx: &mut App,
899 ) -> Result<(), String> {
900 Err("primitive does not support accessibility actions".to_owned())
901 }
902
903 fn mount(&mut self, _instance: &PrimitiveInstance) -> Result<(), String> {
909 Ok(())
910 }
911
912 fn update(
918 &mut self,
919 _previous: &PrimitiveInstance,
920 _next: &PrimitiveInstance,
921 ) -> Result<(), String> {
922 Ok(())
923 }
924
925 fn suspend(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
929
930 fn resume(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
933
934 fn commit_resume(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
937
938 fn render(
944 &mut self,
945 instance: &PrimitiveInstance,
946 events: &PrimitiveEventEmitter,
947 theme: &PrimitiveTheme,
948 window: &mut Window,
949 cx: &mut App,
950 ) -> Result<AnyElement, String>;
951
952 fn unmount(&mut self, _instance: &PrimitiveInstanceId) {}
954}
955
956#[derive(Clone, Debug, Default, PartialEq)]
957pub struct PrimitiveAccessibilityProjection {
958 pub description: String,
959 pub value: Option<UiValue>,
960}
961
962struct PrimitiveEntry {
963 descriptor: PrimitiveDescriptor,
964 handler: Box<dyn PrimitiveHandler>,
965}
966
967#[derive(Default)]
968struct PrimitiveRegistryInner {
969 entries: BTreeMap<PrimitiveId, PrimitiveEntry>,
970 mounted: BTreeMap<PrimitiveInstanceId, PrimitiveInstance>,
971}
972
973#[derive(Clone, Default)]
974pub struct PrimitiveRegistry {
975 inner: Rc<RefCell<PrimitiveRegistryInner>>,
976}
977
978impl fmt::Debug for PrimitiveRegistry {
979 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
980 match self.inner.try_borrow() {
981 Ok(inner) => formatter
982 .debug_struct("PrimitiveRegistry")
983 .field("registered", &inner.entries.keys().collect::<Vec<_>>())
984 .field("mounted", &inner.mounted.keys().collect::<Vec<_>>())
985 .finish(),
986 Err(_) => formatter.write_str("PrimitiveRegistry(<borrowed>)"),
987 }
988 }
989}
990
991impl PrimitiveRegistry {
992 #[must_use]
993 pub fn new() -> Self {
994 Self::default()
995 }
996
997 pub fn register(
1004 &self,
1005 descriptor: PrimitiveDescriptor,
1006 handler: impl PrimitiveHandler + 'static,
1007 ) -> Result<(), PrimitiveError> {
1008 validate_descriptor(&descriptor)?;
1009 let mut inner = self
1010 .inner
1011 .try_borrow_mut()
1012 .map_err(|_| PrimitiveError::Borrowed)?;
1013 if inner.entries.contains_key(&descriptor.id) {
1014 return Err(PrimitiveError::Duplicate(descriptor.id));
1015 }
1016 if inner.entries.values().any(|entry| {
1017 entry.descriptor.id.namespace() == descriptor.id.namespace()
1018 && entry.descriptor.export == descriptor.export
1019 }) {
1020 return Err(PrimitiveError::DuplicateExport {
1021 namespace: descriptor.id.namespace().to_owned(),
1022 export: descriptor.export,
1023 });
1024 }
1025 inner.entries.insert(
1026 descriptor.id.clone(),
1027 PrimitiveEntry {
1028 descriptor,
1029 handler: Box::new(handler),
1030 },
1031 );
1032 Ok(())
1033 }
1034
1035 pub fn create_node(
1042 &self,
1043 id: &PrimitiveId,
1044 key: Option<String>,
1045 props: &Map,
1046 generation: ScriptGeneration,
1047 ) -> Result<UiNode, PrimitiveError> {
1048 let inner = self
1049 .inner
1050 .try_borrow()
1051 .map_err(|_| PrimitiveError::Borrowed)?;
1052 let descriptor = &inner
1053 .entries
1054 .get(id)
1055 .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
1056 .descriptor;
1057 if (descriptor.lifecycle || !descriptor.state.is_empty()) && key.is_none() {
1058 return Err(PrimitiveError::MissingKey(id.clone()));
1059 }
1060 let schema = ValueSchema::object(descriptor.props.clone());
1061 schema
1062 .validate(&Dynamic::from_map(props.clone()))
1063 .map_err(|source| PrimitiveError::InvalidProps {
1064 primitive: id.clone(),
1065 source,
1066 })?;
1067 let props = convert_props(&descriptor.props, props, generation)?;
1068 Ok(UiNode::custom(PrimitiveNode {
1069 primitive: id.clone(),
1070 key,
1071 props,
1072 }))
1073 }
1074
1075 pub fn normalize_event(
1081 &self,
1082 id: &PrimitiveId,
1083 event: &str,
1084 payload: UiValue,
1085 ) -> Result<UiValue, PrimitiveError> {
1086 let inner = self
1087 .inner
1088 .try_borrow()
1089 .map_err(|_| PrimitiveError::Borrowed)?;
1090 let descriptor = &inner
1091 .entries
1092 .get(id)
1093 .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
1094 .descriptor;
1095 let schema = descriptor
1096 .events
1097 .get(event)
1098 .ok_or_else(|| PrimitiveError::UnknownEvent {
1099 primitive: id.clone(),
1100 event: event.to_owned(),
1101 })?;
1102 schema
1103 .payload
1104 .validate_ui_value(&payload)
1105 .map_err(|source| PrimitiveError::InvalidEvent {
1106 primitive: id.clone(),
1107 event: event.to_owned(),
1108 source,
1109 })?;
1110 Ok(payload)
1111 }
1112
1113 pub(crate) fn accessibility_projections(
1114 &self,
1115 tree: &crate::RetainedUiTree,
1116 cx: &App,
1117 ) -> BTreeMap<crate::NodeId, PrimitiveAccessibilityProjection> {
1118 let Ok(inner) = self.inner.try_borrow() else {
1119 return BTreeMap::new();
1120 };
1121 tree.nodes()
1122 .filter_map(|node| {
1123 let primitive = node.primitive()?.clone();
1124 let instance = PrimitiveInstanceId {
1125 primitive: primitive.clone(),
1126 key: node.key()?.to_owned(),
1127 node: node.id(),
1128 };
1129 let projection = inner
1130 .entries
1131 .get(&primitive)?
1132 .handler
1133 .accessibility(&instance, cx)?;
1134 Some((node.id(), projection))
1135 })
1136 .collect()
1137 }
1138
1139 pub(crate) fn accessibility_actions(
1140 &self,
1141 instance: &PrimitiveInstanceId,
1142 ) -> Vec<gpui::AccessibleAction> {
1143 let Ok(inner) = self.inner.try_borrow() else {
1144 return Vec::new();
1145 };
1146 inner
1147 .entries
1148 .get(&instance.primitive)
1149 .map_or_else(Vec::new, |entry| {
1150 entry.handler.accessibility_actions(instance)
1151 })
1152 }
1153
1154 pub(crate) fn uses_primary_focus(&self, primitive: &PrimitiveId) -> bool {
1155 self.inner.try_borrow().ok().is_some_and(|inner| {
1156 inner
1157 .entries
1158 .get(primitive)
1159 .is_some_and(|entry| entry.handler.uses_primary_focus())
1160 })
1161 }
1162
1163 pub(crate) fn perform_accessibility_action(
1164 &self,
1165 instance: &PrimitiveInstanceId,
1166 action: gpui::AccessibleAction,
1167 data: Option<&gpui::accesskit::ActionData>,
1168 window: &mut Window,
1169 cx: &mut App,
1170 ) -> Result<(), PrimitiveError> {
1171 let mut inner = self
1172 .inner
1173 .try_borrow_mut()
1174 .map_err(|_| PrimitiveError::Borrowed)?;
1175 if !inner.mounted.contains_key(instance) {
1176 return Err(PrimitiveError::MissingInstance(instance.clone()));
1177 }
1178 let entry = inner
1179 .entries
1180 .get_mut(&instance.primitive)
1181 .ok_or_else(|| PrimitiveError::Unknown(instance.primitive.clone()))?;
1182 guard_primitive_panic(&instance.primitive, "accessibility action", || {
1183 entry
1184 .handler
1185 .perform_accessibility_action(instance, action, data, window, cx)
1186 })?
1187 .map_err(|message| PrimitiveError::Handler {
1188 primitive: instance.primitive.clone(),
1189 message,
1190 })
1191 }
1192
1193 pub fn retain_mounted(
1199 &self,
1200 active: &BTreeSet<PrimitiveInstanceId>,
1201 ) -> Result<(), PrimitiveError> {
1202 let mut inner = self
1203 .inner
1204 .try_borrow_mut()
1205 .map_err(|_| PrimitiveError::Borrowed)?;
1206 let removed = inner
1207 .mounted
1208 .keys()
1209 .filter(|instance| !active.contains(*instance))
1210 .cloned()
1211 .collect::<Vec<_>>();
1212 let mut first_error = None;
1213 for instance in &removed {
1214 let resources = inner
1215 .mounted
1216 .get(instance)
1217 .and_then(|mounted| mounted.resources.clone());
1218 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1219 && let Err(error) = guard_primitive_panic(&instance.primitive, "unmount", || {
1220 entry.handler.unmount(instance);
1221 })
1222 && first_error.is_none()
1223 {
1224 first_error = Some(error);
1225 }
1226 if let Some(resources) = resources
1227 && let Err(error) = resources.close()
1228 && first_error.is_none()
1229 {
1230 first_error = Some(PrimitiveError::Resource(error));
1231 }
1232 inner.mounted.remove(instance);
1233 }
1234 first_error.map_or(Ok(()), Err)
1235 }
1236
1237 pub fn retain_tree(&self, tree: &crate::RetainedUiTree) -> Result<(), PrimitiveError> {
1243 let active = collect_primitive_instances(tree);
1244 self.retain_mounted(&active)
1245 }
1246
1247 pub(crate) fn suspend_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1248 let mut inner = self
1249 .inner
1250 .try_borrow_mut()
1251 .map_err(|_| PrimitiveError::Borrowed)?;
1252 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1253 let mut first_error = None;
1254 for instance in instances {
1255 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1256 && let Err(error) = guard_primitive_panic(&instance.primitive, "suspend", || {
1257 entry.handler.suspend(&instance, cx);
1258 })
1259 && first_error.is_none()
1260 {
1261 first_error = Some(error);
1262 }
1263 }
1264 first_error.map_or(Ok(()), Err)
1265 }
1266
1267 pub(crate) fn resume_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1268 let mut inner = self
1269 .inner
1270 .try_borrow_mut()
1271 .map_err(|_| PrimitiveError::Borrowed)?;
1272 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1273 let mut first_error = None;
1274 for instance in instances {
1275 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1276 && let Err(error) = guard_primitive_panic(&instance.primitive, "resume", || {
1277 entry.handler.resume(&instance, cx);
1278 })
1279 && first_error.is_none()
1280 {
1281 first_error = Some(error);
1282 }
1283 }
1284 first_error.map_or(Ok(()), Err)
1285 }
1286
1287 pub(crate) fn commit_resume_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1288 let mut inner = self
1289 .inner
1290 .try_borrow_mut()
1291 .map_err(|_| PrimitiveError::Borrowed)?;
1292 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1293 let mut first_error = None;
1294 for instance in instances {
1295 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1296 && let Err(error) =
1297 guard_primitive_panic(&instance.primitive, "commit resume", || {
1298 entry.handler.commit_resume(&instance, cx);
1299 })
1300 && first_error.is_none()
1301 {
1302 first_error = Some(error);
1303 }
1304 }
1305 first_error.map_or(Ok(()), Err)
1306 }
1307
1308 pub(crate) fn element(
1309 &self,
1310 node: PrimitiveNode,
1311 retained_id: Option<crate::NodeId>,
1312 focus_handle: Option<gpui::FocusHandle>,
1313 fallback: Option<UiNode>,
1314 dispatcher: Option<NodeEventDispatcher>,
1315 theme: PrimitiveTheme,
1316 ) -> AnyElement {
1317 RegisteredPrimitiveElement {
1318 registry: self.clone(),
1319 node,
1320 retained_id,
1321 focus_handle,
1322 fallback,
1323 dispatcher,
1324 theme,
1325 }
1326 .into_any_element()
1327 }
1328
1329 #[allow(clippy::too_many_lines)]
1330 fn render_instance(
1331 &self,
1332 node: PrimitiveNode,
1333 identity: PrimitiveRenderIdentity,
1334 events: &PrimitiveEventEmitter,
1335 theme: &PrimitiveTheme,
1336 window: &mut Window,
1337 cx: &mut App,
1338 ) -> Result<AnyElement, PrimitiveError> {
1339 let mut inner = self
1340 .inner
1341 .try_borrow_mut()
1342 .map_err(|_| PrimitiveError::Borrowed)?;
1343 let retained_instance = primitive_is_retained(&inner, &node.primitive)?;
1344 if retained_instance && identity.retained_id.is_none() {
1345 return Err(PrimitiveError::MissingRetainedIdentity(node.primitive));
1346 }
1347 let instance_id = retained_instance.then(|| PrimitiveInstanceId {
1348 primitive: node.primitive.clone(),
1349 key: node
1350 .key
1351 .clone()
1352 .expect("retained primitive descriptors require a key"),
1353 node: identity
1354 .retained_id
1355 .expect("retained primitive renderer supplies NodeId"),
1356 });
1357 let previous = instance_id
1358 .as_ref()
1359 .and_then(|id| inner.mounted.get(id))
1360 .cloned();
1361 let resources = if retained_instance {
1362 Some(
1363 previous
1364 .as_ref()
1365 .and_then(|instance| instance.resources.clone())
1366 .unwrap_or_default(),
1367 )
1368 } else {
1369 None
1370 };
1371 let checkpoint = resources
1372 .as_ref()
1373 .map(PrimitiveResourceScope::checkpoint)
1374 .transpose()?;
1375 let instance = PrimitiveInstance {
1376 id: instance_id.clone(),
1377 node,
1378 resources: resources.clone(),
1379 focus_handle: identity.focus_handle,
1380 };
1381 let needs_mount = instance_id.is_some() && previous.is_none();
1382 if needs_mount
1383 && let Some(effect) = inner
1384 .entries
1385 .get(&instance.node.primitive)
1386 .and_then(|entry| entry.descriptor.effect.as_ref())
1387 {
1388 let mounted = inner
1389 .mounted
1390 .keys()
1391 .filter(|id| id.primitive == instance.node.primitive)
1392 .count();
1393 if mounted >= effect.max_instances {
1394 return Err(PrimitiveError::EffectInstanceBudget {
1395 primitive: instance.node.primitive.clone(),
1396 actual: mounted.saturating_add(1),
1397 limit: effect.max_instances,
1398 });
1399 }
1400 }
1401 let entry = inner
1402 .entries
1403 .get_mut(&instance.node.primitive)
1404 .ok_or_else(|| PrimitiveError::Unknown(instance.node.primitive.clone()))?;
1405 if let Some(effect) = &entry.descriptor.effect {
1406 let cost = guard_primitive_panic(&instance.node.primitive, "effect_cost", || {
1407 entry.handler.effect_cost(&instance)
1408 })?;
1409 if cost > effect.max_cost_per_instance {
1410 return Err(PrimitiveError::EffectCostBudget {
1411 primitive: instance.node.primitive.clone(),
1412 actual: cost,
1413 limit: effect.max_cost_per_instance,
1414 });
1415 }
1416 }
1417 let operation = (|| {
1418 if needs_mount {
1419 guard_primitive_panic(&instance.node.primitive, "mount", || {
1420 entry.handler.mount(&instance)
1421 })?
1422 .map_err(|message| PrimitiveError::Handler {
1423 primitive: instance.node.primitive.clone(),
1424 message,
1425 })?;
1426 }
1427 if let Some(previous) = &previous
1428 && previous.node != instance.node
1429 {
1430 guard_primitive_panic(&instance.node.primitive, "update", || {
1431 entry.handler.update(previous, &instance)
1432 })?
1433 .map_err(|message| PrimitiveError::Handler {
1434 primitive: instance.node.primitive.clone(),
1435 message,
1436 })?;
1437 }
1438 guard_primitive_panic(&instance.node.primitive, "render", || {
1439 entry.handler.render(&instance, events, theme, window, cx)
1440 })?
1441 .map_err(|message| PrimitiveError::Handler {
1442 primitive: instance.node.primitive.clone(),
1443 message,
1444 })
1445 })();
1446 let element = match operation {
1447 Ok(element) => element,
1448 Err(error) => {
1449 return Err(rollback_failed_primitive_operation(
1450 entry,
1451 &instance,
1452 needs_mount,
1453 resources.as_ref(),
1454 checkpoint,
1455 error,
1456 ));
1457 }
1458 };
1459 if let Some(id) = instance_id {
1460 inner.mounted.insert(id, instance);
1461 }
1462 Ok(element)
1463 }
1464}
1465
1466fn rollback_failed_primitive_operation(
1467 entry: &mut PrimitiveEntry,
1468 instance: &PrimitiveInstance,
1469 needs_unmount: bool,
1470 resources: Option<&PrimitiveResourceScope>,
1471 checkpoint: Option<u64>,
1472 original: PrimitiveError,
1473) -> PrimitiveError {
1474 let mut rollback_error = None;
1475 if needs_unmount
1476 && let Some(instance_id) = instance.id.as_ref()
1477 && let Err(error) =
1478 guard_primitive_panic(&instance.node.primitive, "failed-mount unmount", || {
1479 entry.handler.unmount(instance_id);
1480 })
1481 {
1482 rollback_error = Some(error);
1483 }
1484 if let (Some(resources), Some(checkpoint)) = (resources, checkpoint)
1485 && let Err(error) = resources.rollback(checkpoint)
1486 && rollback_error.is_none()
1487 {
1488 rollback_error = Some(PrimitiveError::Resource(error));
1489 }
1490 rollback_error.unwrap_or(original)
1491}
1492
1493fn primitive_is_retained(
1494 inner: &PrimitiveRegistryInner,
1495 primitive: &PrimitiveId,
1496) -> Result<bool, PrimitiveError> {
1497 let descriptor = &inner
1498 .entries
1499 .get(primitive)
1500 .ok_or_else(|| PrimitiveError::Unknown(primitive.clone()))?
1501 .descriptor;
1502 Ok(descriptor.lifecycle || !descriptor.state.is_empty())
1503}
1504
1505fn collect_primitive_instances(tree: &crate::RetainedUiTree) -> BTreeSet<PrimitiveInstanceId> {
1506 tree.nodes()
1507 .filter_map(|node| {
1508 Some(PrimitiveInstanceId {
1509 primitive: node.primitive()?.clone(),
1510 key: node.key()?.to_owned(),
1511 node: node.id(),
1512 })
1513 })
1514 .collect()
1515}
1516
1517fn guard_primitive_panic<T>(
1518 primitive: &PrimitiveId,
1519 phase: &'static str,
1520 operation: impl FnOnce() -> T,
1521) -> Result<T, PrimitiveError> {
1522 catch_unwind(AssertUnwindSafe(operation)).map_err(|_| PrimitiveError::Panic {
1523 primitive: primitive.clone(),
1524 phase,
1525 })
1526}
1527
1528#[derive(gpui::IntoElement)]
1529struct RegisteredPrimitiveElement {
1530 registry: PrimitiveRegistry,
1531 node: PrimitiveNode,
1532 retained_id: Option<crate::NodeId>,
1533 focus_handle: Option<gpui::FocusHandle>,
1534 fallback: Option<UiNode>,
1535 dispatcher: Option<NodeEventDispatcher>,
1536 theme: PrimitiveTheme,
1537}
1538
1539struct PrimitiveRenderIdentity {
1540 retained_id: Option<crate::NodeId>,
1541 focus_handle: Option<gpui::FocusHandle>,
1542}
1543
1544impl RenderOnce for RegisteredPrimitiveElement {
1545 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1546 let registry = self.registry;
1547 let callbacks = self
1548 .node
1549 .props
1550 .iter()
1551 .filter_map(|(name, value)| {
1552 name.strip_prefix("on_").and_then(|event| match value {
1553 PrimitiveValue::Callback(callback) => {
1554 Some((event.to_owned(), callback.clone()))
1555 }
1556 _ => None,
1557 })
1558 })
1559 .collect();
1560 let events = PrimitiveEventEmitter {
1561 registry: Rc::downgrade(®istry.inner),
1562 primitive: self.node.primitive.clone(),
1563 callbacks,
1564 dispatcher: self.dispatcher,
1565 };
1566 match registry.render_instance(
1567 self.node,
1568 PrimitiveRenderIdentity {
1569 retained_id: self.retained_id,
1570 focus_handle: self.focus_handle,
1571 },
1572 &events,
1573 &self.theme,
1574 window,
1575 cx,
1576 ) {
1577 Ok(element) => element,
1578 Err(error) => self.fallback.map_or_else(
1579 || {
1580 div()
1581 .child(format!("Custom primitive error: {error}"))
1582 .into_any_element()
1583 },
1584 |fallback| {
1585 crate::GpuiNodeRenderer::render_with_primitives(
1586 &fallback,
1587 &crate::LiteralColorResolver,
1588 &crate::InteractionState::default(),
1589 ®istry,
1590 )
1591 },
1592 ),
1593 }
1594 }
1595}
1596
1597fn validate_descriptor(descriptor: &PrimitiveDescriptor) -> Result<(), PrimitiveError> {
1598 if !is_pascal_case(&descriptor.export) {
1599 return Err(PrimitiveError::InvalidExport(descriptor.export.clone()));
1600 }
1601 for (name, field) in &descriptor.props {
1602 if !is_identifier(name) {
1603 return Err(PrimitiveError::InvalidPropName(name.clone()));
1604 }
1605 field
1606 .schema
1607 .validate_definition()
1608 .map_err(|source| PrimitiveError::InvalidSchema {
1609 location: format!("prop `{name}`"),
1610 source,
1611 })?;
1612 if let Some(default) = &field.default {
1613 field.schema.validate_ui_value(default).map_err(|source| {
1614 PrimitiveError::InvalidDefault {
1615 prop: name.clone(),
1616 source,
1617 }
1618 })?;
1619 }
1620 }
1621 for (name, event) in &descriptor.events {
1622 event
1623 .payload
1624 .validate_definition()
1625 .map_err(|source| PrimitiveError::InvalidSchema {
1626 location: format!("event `{name}`"),
1627 source,
1628 })?;
1629 let callback = format!("on_{name}");
1630 if !descriptor
1631 .props
1632 .get(&callback)
1633 .is_some_and(|field| schema_accepts_callback(&field.schema))
1634 {
1635 return Err(PrimitiveError::MissingEventCallback {
1636 event: name.clone(),
1637 prop: callback,
1638 });
1639 }
1640 }
1641 ComponentStateSchema::new(descriptor.state.fields().clone())
1642 .map_err(|source| PrimitiveError::InvalidStateSchema(source.to_string()))?;
1643 if let Some(effect) = &descriptor.effect {
1644 if effect.platforms.is_empty() || !effect.platforms.contains(&PrimitivePlatform::current())
1645 {
1646 return Err(PrimitiveError::UnsupportedEffectPlatform {
1647 primitive: descriptor.id.clone(),
1648 platform: PrimitivePlatform::current(),
1649 });
1650 }
1651 if effect.max_instances == 0 || effect.max_cost_per_instance == 0 {
1652 return Err(PrimitiveError::InvalidEffectBudget(descriptor.id.clone()));
1653 }
1654 if !descriptor.lifecycle {
1655 return Err(PrimitiveError::EffectRequiresLifecycle(
1656 descriptor.id.clone(),
1657 ));
1658 }
1659 }
1660 Ok(())
1661}
1662
1663fn schema_accepts_callback(schema: &ValueSchema) -> bool {
1664 matches!(schema, ValueSchema::Callback)
1665 || matches!(schema, ValueSchema::Optional { value } if schema_accepts_callback(value))
1666 || matches!(schema, ValueSchema::OneOf { variants } if variants.iter().any(schema_accepts_callback))
1667}
1668
1669fn convert_props(
1670 schema: &BTreeMap<String, ObjectField>,
1671 values: &Map,
1672 generation: ScriptGeneration,
1673) -> Result<PrimitiveProps, PrimitiveError> {
1674 let mut converted = BTreeMap::new();
1675 for (name, field) in schema {
1676 let value = values
1677 .get(name.as_str())
1678 .cloned()
1679 .or_else(|| field.default.clone().map(UiValue::into_dynamic));
1680 if let Some(value) = value {
1681 converted.insert(
1682 name.clone(),
1683 convert_prop(&field.schema, value, generation).map_err(|source| {
1684 PrimitiveError::PropConversion {
1685 prop: name.clone(),
1686 source,
1687 }
1688 })?,
1689 );
1690 }
1691 }
1692 Ok(PrimitiveProps(converted))
1693}
1694
1695fn convert_prop(
1696 schema: &ValueSchema,
1697 value: Dynamic,
1698 generation: ScriptGeneration,
1699) -> Result<PrimitiveValue, PrimitivePropConversionError> {
1700 match schema {
1701 ValueSchema::Optional { value: inner } if value.is_unit() => {
1702 Ok(PrimitiveValue::Data(UiValue::Null))
1703 }
1704 ValueSchema::Optional { value: inner } => convert_prop(inner, value, generation),
1705 ValueSchema::OneOf { variants } => {
1706 let branch = variants
1707 .iter()
1708 .find(|variant| variant.validate(&value).is_ok())
1709 .expect("validated primitive one_of prop matches one branch");
1710 convert_prop(branch, value, generation)
1711 }
1712 ValueSchema::Node => Ok(PrimitiveValue::Node(Box::new(value.cast::<UiNode>()))),
1713 ValueSchema::Callback if value.is::<FnPtr>() => {
1714 Ok(PrimitiveValue::Callback(UiEventHandler::Script(
1715 ScriptCallback::try_from_fn_ptr(value.cast::<FnPtr>(), generation)?,
1716 )))
1717 }
1718 ValueSchema::Callback => Ok(PrimitiveValue::Callback(UiEventHandler::Native(
1719 value.cast::<crate::NativeHandlerRef>(),
1720 ))),
1721 ValueSchema::Array { items, .. } if matches!(items.as_ref(), ValueSchema::Node) => {
1722 Ok(PrimitiveValue::Nodes(
1723 value
1724 .cast::<Array>()
1725 .into_iter()
1726 .map(Dynamic::cast::<UiNode>)
1727 .collect(),
1728 ))
1729 }
1730 ValueSchema::Style => Ok(PrimitiveValue::Style(Box::new(value.cast::<Style>()))),
1731 ValueSchema::Length => Ok(PrimitiveValue::Length(value.cast::<Length>())),
1732 ValueSchema::Asset => Ok(PrimitiveValue::Asset(value.cast::<AssetId>())),
1733 ValueSchema::Signal => Ok(PrimitiveValue::Signal(value.cast::<crate::NativeSignal>())),
1734 ValueSchema::Ref => Ok(PrimitiveValue::Ref(value.cast::<crate::ElementRef>())),
1735 ValueSchema::Document => Ok(PrimitiveValue::Document(
1736 value.cast::<crate::NativeTextDocument>(),
1737 )),
1738 #[cfg(feature = "charts")]
1739 ValueSchema::ChartData => Ok(PrimitiveValue::ChartData(
1740 value.cast::<crate::NativeChartData>(),
1741 )),
1742 _ => UiValue::from_dynamic(value)
1743 .map(PrimitiveValue::Data)
1744 .map_err(Into::into),
1745 }
1746}
1747
1748#[derive(Debug, Error)]
1749pub enum PrimitivePropConversionError {
1750 #[error(transparent)]
1751 Value(#[from] UiValueError),
1752 #[error(transparent)]
1753 Callback(#[from] crate::ScriptCallbackDefinitionError),
1754}
1755
1756#[derive(Debug, Error)]
1757pub enum PrimitiveError {
1758 #[error("primitive ID `{0}` must be `namespace.snake_case_name`")]
1759 InvalidId(String),
1760 #[error("primitive export `{0}` must be PascalCase")]
1761 InvalidExport(String),
1762 #[error("primitive prop `{0}` must be `snake_case`")]
1763 InvalidPropName(String),
1764 #[error("invalid schema definition for primitive {location}: {source}")]
1765 InvalidSchema {
1766 location: String,
1767 source: SchemaDefinitionError,
1768 },
1769 #[error("invalid primitive state schema: {0}")]
1770 InvalidStateSchema(String),
1771 #[error("primitive registry is already borrowed during rendering")]
1772 Borrowed,
1773 #[error("primitive `{0:?}` is already registered")]
1774 Duplicate(PrimitiveId),
1775 #[error("primitive export `{namespace}::{export}` is already registered")]
1776 DuplicateExport { namespace: String, export: String },
1777 #[error("primitive `{0:?}` is not registered")]
1778 Unknown(PrimitiveId),
1779 #[error("primitive `{0:?}` requires a stable key")]
1780 MissingKey(PrimitiveId),
1781 #[error("primitive `{0:?}` requires a retained NodeId renderer")]
1782 MissingRetainedIdentity(PrimitiveId),
1783 #[error("primitive accessibility action targeted stale instance {0:?}")]
1784 MissingInstance(PrimitiveInstanceId),
1785 #[error("primitive event emitter outlived its registry")]
1786 RegistryReleased,
1787 #[error("props for primitive `{primitive:?}` are invalid: {source}")]
1788 InvalidProps {
1789 primitive: PrimitiveId,
1790 source: SchemaValidationError,
1791 },
1792 #[error("default for primitive prop `{prop}` is invalid: {source}")]
1793 InvalidDefault {
1794 prop: String,
1795 source: SchemaValidationError,
1796 },
1797 #[error("primitive prop `{prop}` cannot cross the runtime boundary: {source}")]
1798 PropConversion {
1799 prop: String,
1800 source: PrimitivePropConversionError,
1801 },
1802 #[error("primitive event `{event}` requires callback prop `{prop}`")]
1803 MissingEventCallback { event: String, prop: String },
1804 #[error("primitive `{primitive:?}` does not declare event `{event}`")]
1805 UnknownEvent {
1806 primitive: PrimitiveId,
1807 event: String,
1808 },
1809 #[error("primitive `{primitive:?}` event `{event}` is invalid: {source}")]
1810 InvalidEvent {
1811 primitive: PrimitiveId,
1812 event: String,
1813 source: SchemaValidationError,
1814 },
1815 #[error("primitive `{primitive:?}` handler failed: {message}")]
1816 Handler {
1817 primitive: PrimitiveId,
1818 message: String,
1819 },
1820 #[error("primitive `{primitive:?}` panicked during {phase}")]
1821 Panic {
1822 primitive: PrimitiveId,
1823 phase: &'static str,
1824 },
1825 #[error("effect primitive `{primitive:?}` does not support {platform:?}")]
1826 UnsupportedEffectPlatform {
1827 primitive: PrimitiveId,
1828 platform: PrimitivePlatform,
1829 },
1830 #[error("effect primitive `{0:?}` must declare positive instance and cost budgets")]
1831 InvalidEffectBudget(PrimitiveId),
1832 #[error("effect primitive `{0:?}` must opt into scoped lifecycle")]
1833 EffectRequiresLifecycle(PrimitiveId),
1834 #[error("effect primitive `{primitive:?}` instance budget exceeded: {actual} > {limit}")]
1835 EffectInstanceBudget {
1836 primitive: PrimitiveId,
1837 actual: usize,
1838 limit: usize,
1839 },
1840 #[error("effect primitive `{primitive:?}` cost budget exceeded: {actual} > {limit}")]
1841 EffectCostBudget {
1842 primitive: PrimitiveId,
1843 actual: usize,
1844 limit: usize,
1845 },
1846 #[error(transparent)]
1847 Resource(#[from] PrimitiveResourceError),
1848}
1849
1850#[cfg(test)]
1851mod tests {
1852 use super::*;
1853 use crate::{ObjectField, StateField};
1854 use std::cell::Cell;
1855
1856 struct TestHandler;
1857
1858 struct TestTheme;
1859
1860 impl ColorResolver for TestTheme {
1861 fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
1862 match color {
1863 ColorValue::Token(token)
1864 if matches!(token.as_str(), "accent" | "selection" | "table.selection") =>
1865 {
1866 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1867 }
1868 _ => None,
1869 }
1870 }
1871
1872 fn resolve_length(&self, length: Length) -> Option<Length> {
1873 match length {
1874 Length::ThemeSpacing(SpacingToken::Xxs) => Some(Length::Pixels(2.0)),
1875 Length::ThemeSpacing(SpacingToken::Sm) => Some(Length::Pixels(6.0)),
1876 _ => None,
1877 }
1878 }
1879
1880 fn resolve_typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
1881 (role == "body").then(|| crate::ResolvedTypography {
1882 family: Some("JetBrains Mono".to_owned()),
1883 fallbacks: vec!["PingFang SC".to_owned()],
1884 size: Length::Pixels(12.0),
1885 line_height: Length::Pixels(16.0),
1886 weight: 400,
1887 })
1888 }
1889 }
1890
1891 impl PrimitiveHandler for TestHandler {
1892 fn render(
1893 &mut self,
1894 _: &PrimitiveInstance,
1895 _: &PrimitiveEventEmitter,
1896 _: &PrimitiveTheme,
1897 _: &mut Window,
1898 _: &mut App,
1899 ) -> Result<AnyElement, String> {
1900 Ok(div().into_any_element())
1901 }
1902 }
1903
1904 #[test]
1905 fn primitive_theme_exposes_only_resolved_semantic_snapshot() {
1906 let theme = PrimitiveTheme::capture(&TestTheme);
1907 assert_eq!(
1908 theme.color("accent"),
1909 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1910 );
1911 assert_eq!(theme.color("unknown"), None);
1912 assert_eq!(
1913 theme.color("selection"),
1914 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1915 );
1916 assert_eq!(
1917 theme.color("table.selection"),
1918 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1919 );
1920 assert_eq!(
1921 theme.resolve_color(&ColorValue::Literal(Rgba8::from_rgba_hex(0xaabb_ccdd))),
1922 Some(Rgba8::from_rgba_hex(0xaabb_ccdd))
1923 );
1924 assert_eq!(
1925 theme.resolve_length(Length::ThemeSpacing(SpacingToken::Sm)),
1926 Some(Length::Pixels(6.0))
1927 );
1928 assert_eq!(
1929 theme.resolve_length(Length::ThemeSpacing(SpacingToken::Xxs)),
1930 Some(Length::Pixels(2.0))
1931 );
1932 assert_eq!(
1933 theme.typography("body").unwrap().family.as_deref(),
1934 Some("JetBrains Mono")
1935 );
1936
1937 let engine = crate::RuntimeEngine::new();
1938 let loaded = crate::load_theme_source(
1939 engine.engine(),
1940 "default_light.rhai",
1941 include_str!("../../../registry/themes/default_light.rhai"),
1942 )
1943 .unwrap();
1944 let captured = PrimitiveTheme::capture(&loaded);
1945 assert_eq!(
1946 captured.color("table.selection"),
1947 loaded.tokens.color("table.selection")
1948 );
1949 assert!(captured.color("table.selection").is_some());
1950 }
1951
1952 fn descriptor() -> PrimitiveDescriptor {
1953 PrimitiveDescriptor {
1954 id: PrimitiveId::parse("my_app.code_editor").unwrap(),
1955 export: "CodeEditor".to_owned(),
1956 props: BTreeMap::from([
1957 (
1958 "value".to_owned(),
1959 ObjectField::required(ValueSchema::string()),
1960 ),
1961 (
1962 "on_change".to_owned(),
1963 ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
1964 ),
1965 ]),
1966 events: BTreeMap::from([(
1967 "change".to_owned(),
1968 EventSchema {
1969 payload: ValueSchema::string(),
1970 },
1971 )]),
1972 state: ComponentStateSchema::new(BTreeMap::from([(
1973 "selection".to_owned(),
1974 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
1975 )]))
1976 .unwrap(),
1977 lifecycle: true,
1978 effect: None,
1979 }
1980 }
1981
1982 #[test]
1983 fn effect_descriptors_require_current_platform_lifecycle_and_budgets() {
1984 let mut descriptor = descriptor();
1985 descriptor.effect = Some(EffectPrimitiveDescriptor {
1986 platforms: BTreeSet::from([PrimitivePlatform::current()]),
1987 max_instances: 8,
1988 max_cost_per_instance: 4_096,
1989 reduced_motion: true,
1990 quality_tiers: true,
1991 });
1992 validate_descriptor(&descriptor).unwrap();
1993 descriptor.lifecycle = false;
1994 assert!(matches!(
1995 validate_descriptor(&descriptor),
1996 Err(PrimitiveError::EffectRequiresLifecycle(_))
1997 ));
1998 }
1999
2000 #[test]
2001 fn custom_primitive_props_and_keys_are_validated() {
2002 let registry = PrimitiveRegistry::new();
2003 let descriptor = descriptor();
2004 let id = descriptor.id.clone();
2005 registry.register(descriptor, TestHandler).unwrap();
2006 assert!(matches!(
2007 registry.create_node(
2008 &id,
2009 None,
2010 &Map::from_iter([("value".into(), Dynamic::from("source"))]),
2011 ScriptGeneration::initial(),
2012 ),
2013 Err(PrimitiveError::MissingKey(_))
2014 ));
2015 registry
2016 .create_node(
2017 &id,
2018 Some("editor".to_owned()),
2019 &Map::from_iter([("value".into(), Dynamic::from("source"))]),
2020 ScriptGeneration::initial(),
2021 )
2022 .unwrap();
2023 }
2024
2025 #[test]
2026 fn custom_primitive_events_are_normalized() {
2027 let registry = PrimitiveRegistry::new();
2028 let descriptor = descriptor();
2029 let id = descriptor.id.clone();
2030 registry.register(descriptor, TestHandler).unwrap();
2031 assert_eq!(
2032 registry
2033 .normalize_event(&id, "change", UiValue::String("new".to_owned()))
2034 .unwrap(),
2035 UiValue::String("new".to_owned())
2036 );
2037 assert!(matches!(
2038 registry.normalize_event(&id, "change", UiValue::Bool(true)),
2039 Err(PrimitiveError::InvalidEvent { .. })
2040 ));
2041 }
2042
2043 #[test]
2044 fn native_panics_are_converted_to_primitive_errors() {
2045 let id = PrimitiveId::parse("my_app.crash").unwrap();
2046 assert!(matches!(
2047 guard_primitive_panic(&id, "render", || panic!("boom")),
2048 Err(PrimitiveError::Panic {
2049 phase: "render",
2050 ..
2051 })
2052 ));
2053 }
2054
2055 #[test]
2056 fn primitive_resource_scope_rolls_back_and_continues_after_cleanup_panic() {
2057 let scope = PrimitiveResourceScope::new();
2058 let retained = Rc::new(Cell::new(0));
2059 let retained_cleanup = Rc::clone(&retained);
2060 scope
2061 .own("retained", move || retained_cleanup.set(1))
2062 .unwrap();
2063 let checkpoint = scope.checkpoint().unwrap();
2064 let order = Rc::new(RefCell::new(Vec::new()));
2065 let first = Rc::clone(&order);
2066 scope
2067 .own("first", move || first.borrow_mut().push(1))
2068 .unwrap();
2069 scope.own("panic", || panic!("cleanup failed")).unwrap();
2070 let last = Rc::clone(&order);
2071 scope
2072 .own("last", move || last.borrow_mut().push(3))
2073 .unwrap();
2074
2075 assert!(matches!(
2076 scope.rollback(checkpoint),
2077 Err(PrimitiveResourceError::CleanupPanic { ref label }) if label == "panic"
2078 ));
2079 assert_eq!(*order.borrow(), vec![3, 1]);
2080 assert_eq!(scope.active_count(), 1);
2081 scope.close().unwrap();
2082 assert_eq!(retained.get(), 1);
2083 }
2084
2085 #[test]
2086 fn successful_tree_cleanup_unmounts_removed_keyed_instances() {
2087 struct UnmountCounter(Rc<Cell<usize>>);
2088 impl PrimitiveHandler for UnmountCounter {
2089 fn render(
2090 &mut self,
2091 _: &PrimitiveInstance,
2092 _: &PrimitiveEventEmitter,
2093 _: &PrimitiveTheme,
2094 _: &mut Window,
2095 _: &mut App,
2096 ) -> Result<AnyElement, String> {
2097 Ok(div().into_any_element())
2098 }
2099
2100 fn unmount(&mut self, _: &PrimitiveInstanceId) {
2101 self.0.set(self.0.get() + 1);
2102 }
2103 }
2104
2105 let registry = PrimitiveRegistry::new();
2106 let descriptor = descriptor();
2107 let node = PrimitiveNode {
2108 primitive: descriptor.id.clone(),
2109 key: Some("editor".to_owned()),
2110 props: PrimitiveProps::new(),
2111 };
2112 let mut tree = crate::RetainedUiTree::new();
2113 tree.reconcile(UiNode::custom(node.clone())).unwrap();
2114 let instance = collect_primitive_instances(&tree)
2115 .into_iter()
2116 .next()
2117 .unwrap();
2118 let unmounted = Rc::new(Cell::new(0));
2119 let cleaned = Rc::new(Cell::new(0));
2120 let resources = PrimitiveResourceScope::new();
2121 let cleanup = Rc::clone(&cleaned);
2122 resources
2123 .own("watcher", move || cleanup.set(cleanup.get() + 1))
2124 .unwrap();
2125 registry
2126 .register(descriptor, UnmountCounter(Rc::clone(&unmounted)))
2127 .unwrap();
2128 registry.inner.borrow_mut().mounted.insert(
2129 instance.clone(),
2130 PrimitiveInstance {
2131 id: Some(instance.clone()),
2132 node,
2133 resources: Some(resources),
2134 focus_handle: None,
2135 },
2136 );
2137 tree.reconcile(UiNode::text("removed")).unwrap();
2138 registry.retain_tree(&tree).unwrap();
2139 assert_eq!(unmounted.get(), 1);
2140 assert_eq!(cleaned.get(), 1);
2141 }
2142
2143 #[test]
2144 fn primitive_identity_uses_retained_node_not_component_local_key() {
2145 let primitive = PrimitiveId::parse("my_app.editor").unwrap();
2146 let branch = |branch: &str| {
2147 UiNode::box_node(vec![UiNode::custom(PrimitiveNode {
2148 primitive: primitive.clone(),
2149 key: Some("editor".to_owned()),
2150 props: PrimitiveProps::new().with(
2151 "branch",
2152 PrimitiveValue::Data(UiValue::String(branch.to_owned())),
2153 ),
2154 })])
2155 .with_key(branch)
2156 };
2157 let mut tree = crate::RetainedUiTree::new();
2158 tree.reconcile(UiNode::box_node(vec![branch("left"), branch("right")]))
2159 .unwrap();
2160 let before = collect_primitive_instances(&tree);
2161 assert_eq!(before.len(), 2);
2162 assert!(before.iter().all(|instance| instance.key() == "editor"));
2163 assert_eq!(
2164 before
2165 .iter()
2166 .map(PrimitiveInstanceId::node)
2167 .collect::<BTreeSet<_>>()
2168 .len(),
2169 2
2170 );
2171
2172 tree.reconcile(UiNode::box_node(vec![branch("right"), branch("left")]))
2173 .unwrap();
2174 assert_eq!(collect_primitive_instances(&tree), before);
2175 }
2176
2177 #[test]
2178 fn primitive_event_emitter_holds_only_a_weak_registry_reference() {
2179 let registry = PrimitiveRegistry::new();
2180 let weak = Rc::downgrade(®istry.inner);
2181 let emitter = PrimitiveEventEmitter {
2182 registry: Rc::downgrade(®istry.inner),
2183 primitive: PrimitiveId::parse("my_app.editor").unwrap(),
2184 callbacks: BTreeMap::new(),
2185 dispatcher: None,
2186 };
2187 assert_eq!(Rc::strong_count(®istry.inner), 1);
2188 drop(registry);
2189 assert!(weak.upgrade().is_none());
2190 drop(emitter);
2191 }
2192}