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}
560
561impl PrimitiveInstance {
562 #[must_use]
566 pub const fn resources(&self) -> Option<&PrimitiveResourceScope> {
567 self.resources.as_ref()
568 }
569}
570
571#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
572pub struct PrimitiveResourceHandle(u64);
573
574struct PrimitiveResourceEntry {
575 label: String,
576 cleanup: Option<Box<dyn FnOnce()>>,
577}
578
579#[derive(Default)]
580struct PrimitiveResourceState {
581 next_id: u64,
582 entries: BTreeMap<u64, PrimitiveResourceEntry>,
583}
584
585impl Drop for PrimitiveResourceState {
586 fn drop(&mut self) {
587 let entries = std::mem::take(&mut self.entries);
588 for entry in entries.into_values().rev() {
589 let _ = run_resource_cleanup(entry);
590 }
591 }
592}
593
594#[derive(Clone, Default)]
595pub struct PrimitiveResourceScope {
596 inner: Rc<RefCell<PrimitiveResourceState>>,
597}
598
599impl fmt::Debug for PrimitiveResourceScope {
600 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
601 match self.inner.try_borrow() {
602 Ok(state) => formatter
603 .debug_struct("PrimitiveResourceScope")
604 .field(
605 "active",
606 &state
607 .entries
608 .values()
609 .map(|entry| entry.label.as_str())
610 .collect::<Vec<_>>(),
611 )
612 .finish(),
613 Err(_) => formatter.write_str("PrimitiveResourceScope(<borrowed>)"),
614 }
615 }
616}
617
618impl PrimitiveResourceScope {
619 #[must_use]
620 pub fn new() -> Self {
621 Self::default()
622 }
623
624 pub fn own(
631 &self,
632 label: impl Into<String>,
633 cleanup: impl FnOnce() + 'static,
634 ) -> Result<PrimitiveResourceHandle, PrimitiveResourceError> {
635 let label = label.into();
636 if label.is_empty()
637 || label.len() > 128
638 || !label.chars().all(|character| {
639 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
640 })
641 {
642 return Err(PrimitiveResourceError::InvalidLabel(label));
643 }
644 let mut state = self
645 .inner
646 .try_borrow_mut()
647 .map_err(|_| PrimitiveResourceError::Borrowed)?;
648 let id = state.next_id.max(1);
649 state.next_id = id
650 .checked_add(1)
651 .ok_or(PrimitiveResourceError::IdExhausted)?;
652 state.entries.insert(
653 id,
654 PrimitiveResourceEntry {
655 label,
656 cleanup: Some(Box::new(cleanup)),
657 },
658 );
659 Ok(PrimitiveResourceHandle(id))
660 }
661
662 pub fn cancel(&self, handle: &PrimitiveResourceHandle) -> Result<bool, PrimitiveResourceError> {
668 let entry = self
669 .inner
670 .try_borrow_mut()
671 .map_err(|_| PrimitiveResourceError::Borrowed)?
672 .entries
673 .remove(&handle.0);
674 let Some(entry) = entry else {
675 return Ok(false);
676 };
677 run_resource_cleanup(entry)?;
678 Ok(true)
679 }
680
681 #[must_use]
682 pub fn active_count(&self) -> usize {
683 self.inner.borrow().entries.len()
684 }
685
686 fn checkpoint(&self) -> Result<u64, PrimitiveResourceError> {
687 self.inner
688 .try_borrow()
689 .map(|state| state.next_id.max(1))
690 .map_err(|_| PrimitiveResourceError::Borrowed)
691 }
692
693 fn rollback(&self, checkpoint: u64) -> Result<(), PrimitiveResourceError> {
694 self.cleanup_where(|id| id >= checkpoint)
695 }
696
697 fn close(&self) -> Result<(), PrimitiveResourceError> {
698 self.cleanup_where(|_| true)
699 }
700
701 fn cleanup_where(&self, predicate: impl Fn(u64) -> bool) -> Result<(), PrimitiveResourceError> {
702 let mut entries = {
703 let mut state = self
704 .inner
705 .try_borrow_mut()
706 .map_err(|_| PrimitiveResourceError::Borrowed)?;
707 let ids = state
708 .entries
709 .keys()
710 .copied()
711 .filter(|id| predicate(*id))
712 .collect::<Vec<_>>();
713 ids.into_iter()
714 .rev()
715 .filter_map(|id| state.entries.remove(&id))
716 .collect::<Vec<_>>()
717 };
718 let mut first_error = None;
719 for entry in entries.drain(..) {
720 if let Err(error) = run_resource_cleanup(entry)
721 && first_error.is_none()
722 {
723 first_error = Some(error);
724 }
725 }
726 first_error.map_or(Ok(()), Err)
727 }
728}
729
730fn run_resource_cleanup(mut entry: PrimitiveResourceEntry) -> Result<(), PrimitiveResourceError> {
731 let label = entry.label;
732 let Some(cleanup) = entry.cleanup.take() else {
733 return Ok(());
734 };
735 catch_unwind(AssertUnwindSafe(cleanup))
736 .map_err(|_| PrimitiveResourceError::CleanupPanic { label })
737}
738
739#[derive(Clone, Debug, Error, Eq, PartialEq)]
740pub enum PrimitiveResourceError {
741 #[error("primitive resource label `{0}` must be 1-128 safe ASCII characters")]
742 InvalidLabel(String),
743 #[error("primitive resource scope is already borrowed")]
744 Borrowed,
745 #[error("primitive resource scope exhausted its handle identity space")]
746 IdExhausted,
747 #[error("primitive resource cleanup `{label}` panicked")]
748 CleanupPanic { label: String },
749}
750
751#[derive(Clone)]
752pub struct PrimitiveEventEmitter {
753 registry: Weak<RefCell<PrimitiveRegistryInner>>,
754 primitive: PrimitiveId,
755 callbacks: BTreeMap<String, UiEventHandler>,
756 dispatcher: Option<NodeEventDispatcher>,
757}
758
759impl PrimitiveEventEmitter {
760 pub fn emit(
767 &self,
768 event: &str,
769 payload: UiValue,
770 window: &mut Window,
771 cx: &mut App,
772 ) -> Result<(), PrimitiveError> {
773 let registry = PrimitiveRegistry {
774 inner: self
775 .registry
776 .upgrade()
777 .ok_or(PrimitiveError::RegistryReleased)?,
778 };
779 let payload = registry.normalize_event(&self.primitive, event, payload)?;
780 if let Some(handler) = self.callbacks.get(event) {
781 match handler {
782 UiEventHandler::Script(callback) => {
783 if let Some(dispatcher) = self.dispatcher.as_ref() {
784 dispatcher.dispatch(callback.clone(), payload, None, window, cx);
785 }
786 }
787 UiEventHandler::Host(callback) => {
788 callback.invoke(payload, window, cx);
789 }
790 UiEventHandler::Native(reference) => {
791 if let Some(dispatcher) = self.dispatcher.as_ref() {
792 dispatcher.dispatch_native(
793 reference.clone(),
794 event.to_owned(),
795 payload,
796 None,
797 window,
798 cx,
799 );
800 }
801 }
802 }
803 }
804 Ok(())
805 }
806
807 pub fn write_signal(
814 &self,
815 signal: &crate::NativeSignal,
816 value: crate::SignalValue,
817 cx: &mut App,
818 ) -> Result<bool, crate::SignalError> {
819 self.dispatcher.as_ref().map_or_else(
820 || Err(crate::SignalError::Stale(signal.id().clone())),
821 |dispatcher| dispatcher.write_signal(signal.clone(), value, cx),
822 )
823 }
824
825 #[must_use]
827 pub fn element_bounds(
828 &self,
829 reference: &crate::ElementRef,
830 cx: &App,
831 ) -> Option<crate::GeometryBounds> {
832 self.dispatcher
833 .as_ref()
834 .and_then(|dispatcher| dispatcher.element_bounds(reference, cx))
835 }
836}
837
838pub trait PrimitiveHandler {
839 fn effect_cost(&self, _instance: &PrimitiveInstance) -> usize {
842 1
843 }
844
845 fn accessibility(
849 &self,
850 _instance: &PrimitiveInstanceId,
851 _cx: &App,
852 ) -> Option<PrimitiveAccessibilityProjection> {
853 None
854 }
855
856 fn accessibility_actions(
861 &self,
862 _instance: &PrimitiveInstanceId,
863 ) -> Vec<gpui::AccessibleAction> {
864 Vec::new()
865 }
866
867 fn perform_accessibility_action(
874 &mut self,
875 _instance: &PrimitiveInstanceId,
876 _action: gpui::AccessibleAction,
877 _data: Option<&gpui::accesskit::ActionData>,
878 _window: &mut Window,
879 _cx: &mut App,
880 ) -> Result<(), String> {
881 Err("primitive does not support accessibility actions".to_owned())
882 }
883
884 fn mount(&mut self, _instance: &PrimitiveInstance) -> Result<(), String> {
890 Ok(())
891 }
892
893 fn update(
899 &mut self,
900 _previous: &PrimitiveInstance,
901 _next: &PrimitiveInstance,
902 ) -> Result<(), String> {
903 Ok(())
904 }
905
906 fn suspend(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
910
911 fn resume(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
914
915 fn commit_resume(&mut self, _instance: &PrimitiveInstanceId, _cx: &mut App) {}
918
919 fn render(
925 &mut self,
926 instance: &PrimitiveInstance,
927 events: &PrimitiveEventEmitter,
928 theme: &PrimitiveTheme,
929 window: &mut Window,
930 cx: &mut App,
931 ) -> Result<AnyElement, String>;
932
933 fn unmount(&mut self, _instance: &PrimitiveInstanceId) {}
935}
936
937#[derive(Clone, Debug, Default, PartialEq)]
938pub struct PrimitiveAccessibilityProjection {
939 pub description: String,
940 pub value: Option<UiValue>,
941}
942
943struct PrimitiveEntry {
944 descriptor: PrimitiveDescriptor,
945 handler: Box<dyn PrimitiveHandler>,
946}
947
948#[derive(Default)]
949struct PrimitiveRegistryInner {
950 entries: BTreeMap<PrimitiveId, PrimitiveEntry>,
951 mounted: BTreeMap<PrimitiveInstanceId, PrimitiveInstance>,
952}
953
954#[derive(Clone, Default)]
955pub struct PrimitiveRegistry {
956 inner: Rc<RefCell<PrimitiveRegistryInner>>,
957}
958
959impl fmt::Debug for PrimitiveRegistry {
960 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
961 match self.inner.try_borrow() {
962 Ok(inner) => formatter
963 .debug_struct("PrimitiveRegistry")
964 .field("registered", &inner.entries.keys().collect::<Vec<_>>())
965 .field("mounted", &inner.mounted.keys().collect::<Vec<_>>())
966 .finish(),
967 Err(_) => formatter.write_str("PrimitiveRegistry(<borrowed>)"),
968 }
969 }
970}
971
972impl PrimitiveRegistry {
973 #[must_use]
974 pub fn new() -> Self {
975 Self::default()
976 }
977
978 pub fn register(
985 &self,
986 descriptor: PrimitiveDescriptor,
987 handler: impl PrimitiveHandler + 'static,
988 ) -> Result<(), PrimitiveError> {
989 validate_descriptor(&descriptor)?;
990 let mut inner = self
991 .inner
992 .try_borrow_mut()
993 .map_err(|_| PrimitiveError::Borrowed)?;
994 if inner.entries.contains_key(&descriptor.id) {
995 return Err(PrimitiveError::Duplicate(descriptor.id));
996 }
997 if inner.entries.values().any(|entry| {
998 entry.descriptor.id.namespace() == descriptor.id.namespace()
999 && entry.descriptor.export == descriptor.export
1000 }) {
1001 return Err(PrimitiveError::DuplicateExport {
1002 namespace: descriptor.id.namespace().to_owned(),
1003 export: descriptor.export,
1004 });
1005 }
1006 inner.entries.insert(
1007 descriptor.id.clone(),
1008 PrimitiveEntry {
1009 descriptor,
1010 handler: Box::new(handler),
1011 },
1012 );
1013 Ok(())
1014 }
1015
1016 pub fn create_node(
1023 &self,
1024 id: &PrimitiveId,
1025 key: Option<String>,
1026 props: &Map,
1027 generation: ScriptGeneration,
1028 ) -> Result<UiNode, PrimitiveError> {
1029 let inner = self
1030 .inner
1031 .try_borrow()
1032 .map_err(|_| PrimitiveError::Borrowed)?;
1033 let descriptor = &inner
1034 .entries
1035 .get(id)
1036 .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
1037 .descriptor;
1038 if (descriptor.lifecycle || !descriptor.state.is_empty()) && key.is_none() {
1039 return Err(PrimitiveError::MissingKey(id.clone()));
1040 }
1041 let schema = ValueSchema::object(descriptor.props.clone());
1042 schema
1043 .validate(&Dynamic::from_map(props.clone()))
1044 .map_err(|source| PrimitiveError::InvalidProps {
1045 primitive: id.clone(),
1046 source,
1047 })?;
1048 let props = convert_props(&descriptor.props, props, generation)?;
1049 Ok(UiNode::custom(PrimitiveNode {
1050 primitive: id.clone(),
1051 key,
1052 props,
1053 }))
1054 }
1055
1056 pub fn normalize_event(
1062 &self,
1063 id: &PrimitiveId,
1064 event: &str,
1065 payload: UiValue,
1066 ) -> Result<UiValue, PrimitiveError> {
1067 let inner = self
1068 .inner
1069 .try_borrow()
1070 .map_err(|_| PrimitiveError::Borrowed)?;
1071 let descriptor = &inner
1072 .entries
1073 .get(id)
1074 .ok_or_else(|| PrimitiveError::Unknown(id.clone()))?
1075 .descriptor;
1076 let schema = descriptor
1077 .events
1078 .get(event)
1079 .ok_or_else(|| PrimitiveError::UnknownEvent {
1080 primitive: id.clone(),
1081 event: event.to_owned(),
1082 })?;
1083 schema
1084 .payload
1085 .validate_ui_value(&payload)
1086 .map_err(|source| PrimitiveError::InvalidEvent {
1087 primitive: id.clone(),
1088 event: event.to_owned(),
1089 source,
1090 })?;
1091 Ok(payload)
1092 }
1093
1094 pub(crate) fn accessibility_projections(
1095 &self,
1096 tree: &crate::RetainedUiTree,
1097 cx: &App,
1098 ) -> BTreeMap<crate::NodeId, PrimitiveAccessibilityProjection> {
1099 let Ok(inner) = self.inner.try_borrow() else {
1100 return BTreeMap::new();
1101 };
1102 tree.nodes()
1103 .filter_map(|node| {
1104 let primitive = node.primitive()?.clone();
1105 let instance = PrimitiveInstanceId {
1106 primitive: primitive.clone(),
1107 key: node.key()?.to_owned(),
1108 node: node.id(),
1109 };
1110 let projection = inner
1111 .entries
1112 .get(&primitive)?
1113 .handler
1114 .accessibility(&instance, cx)?;
1115 Some((node.id(), projection))
1116 })
1117 .collect()
1118 }
1119
1120 pub(crate) fn accessibility_actions(
1121 &self,
1122 instance: &PrimitiveInstanceId,
1123 ) -> Vec<gpui::AccessibleAction> {
1124 let Ok(inner) = self.inner.try_borrow() else {
1125 return Vec::new();
1126 };
1127 inner
1128 .entries
1129 .get(&instance.primitive)
1130 .map_or_else(Vec::new, |entry| {
1131 entry.handler.accessibility_actions(instance)
1132 })
1133 }
1134
1135 pub(crate) fn perform_accessibility_action(
1136 &self,
1137 instance: &PrimitiveInstanceId,
1138 action: gpui::AccessibleAction,
1139 data: Option<&gpui::accesskit::ActionData>,
1140 window: &mut Window,
1141 cx: &mut App,
1142 ) -> Result<(), PrimitiveError> {
1143 let mut inner = self
1144 .inner
1145 .try_borrow_mut()
1146 .map_err(|_| PrimitiveError::Borrowed)?;
1147 if !inner.mounted.contains_key(instance) {
1148 return Err(PrimitiveError::MissingInstance(instance.clone()));
1149 }
1150 let entry = inner
1151 .entries
1152 .get_mut(&instance.primitive)
1153 .ok_or_else(|| PrimitiveError::Unknown(instance.primitive.clone()))?;
1154 guard_primitive_panic(&instance.primitive, "accessibility action", || {
1155 entry
1156 .handler
1157 .perform_accessibility_action(instance, action, data, window, cx)
1158 })?
1159 .map_err(|message| PrimitiveError::Handler {
1160 primitive: instance.primitive.clone(),
1161 message,
1162 })
1163 }
1164
1165 pub fn retain_mounted(
1171 &self,
1172 active: &BTreeSet<PrimitiveInstanceId>,
1173 ) -> Result<(), PrimitiveError> {
1174 let mut inner = self
1175 .inner
1176 .try_borrow_mut()
1177 .map_err(|_| PrimitiveError::Borrowed)?;
1178 let removed = inner
1179 .mounted
1180 .keys()
1181 .filter(|instance| !active.contains(*instance))
1182 .cloned()
1183 .collect::<Vec<_>>();
1184 let mut first_error = None;
1185 for instance in &removed {
1186 let resources = inner
1187 .mounted
1188 .get(instance)
1189 .and_then(|mounted| mounted.resources.clone());
1190 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1191 && let Err(error) = guard_primitive_panic(&instance.primitive, "unmount", || {
1192 entry.handler.unmount(instance);
1193 })
1194 && first_error.is_none()
1195 {
1196 first_error = Some(error);
1197 }
1198 if let Some(resources) = resources
1199 && let Err(error) = resources.close()
1200 && first_error.is_none()
1201 {
1202 first_error = Some(PrimitiveError::Resource(error));
1203 }
1204 inner.mounted.remove(instance);
1205 }
1206 first_error.map_or(Ok(()), Err)
1207 }
1208
1209 pub fn retain_tree(&self, tree: &crate::RetainedUiTree) -> Result<(), PrimitiveError> {
1215 let active = collect_primitive_instances(tree);
1216 self.retain_mounted(&active)
1217 }
1218
1219 pub(crate) fn suspend_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1220 let mut inner = self
1221 .inner
1222 .try_borrow_mut()
1223 .map_err(|_| PrimitiveError::Borrowed)?;
1224 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1225 let mut first_error = None;
1226 for instance in instances {
1227 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1228 && let Err(error) = guard_primitive_panic(&instance.primitive, "suspend", || {
1229 entry.handler.suspend(&instance, cx);
1230 })
1231 && first_error.is_none()
1232 {
1233 first_error = Some(error);
1234 }
1235 }
1236 first_error.map_or(Ok(()), Err)
1237 }
1238
1239 pub(crate) fn resume_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1240 let mut inner = self
1241 .inner
1242 .try_borrow_mut()
1243 .map_err(|_| PrimitiveError::Borrowed)?;
1244 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1245 let mut first_error = None;
1246 for instance in instances {
1247 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1248 && let Err(error) = guard_primitive_panic(&instance.primitive, "resume", || {
1249 entry.handler.resume(&instance, cx);
1250 })
1251 && first_error.is_none()
1252 {
1253 first_error = Some(error);
1254 }
1255 }
1256 first_error.map_or(Ok(()), Err)
1257 }
1258
1259 pub(crate) fn commit_resume_mounted(&self, cx: &mut App) -> Result<(), PrimitiveError> {
1260 let mut inner = self
1261 .inner
1262 .try_borrow_mut()
1263 .map_err(|_| PrimitiveError::Borrowed)?;
1264 let instances = inner.mounted.keys().cloned().collect::<Vec<_>>();
1265 let mut first_error = None;
1266 for instance in instances {
1267 if let Some(entry) = inner.entries.get_mut(&instance.primitive)
1268 && let Err(error) =
1269 guard_primitive_panic(&instance.primitive, "commit resume", || {
1270 entry.handler.commit_resume(&instance, cx);
1271 })
1272 && first_error.is_none()
1273 {
1274 first_error = Some(error);
1275 }
1276 }
1277 first_error.map_or(Ok(()), Err)
1278 }
1279
1280 pub(crate) fn element(
1281 &self,
1282 node: PrimitiveNode,
1283 retained_id: Option<crate::NodeId>,
1284 fallback: Option<UiNode>,
1285 dispatcher: Option<NodeEventDispatcher>,
1286 theme: PrimitiveTheme,
1287 ) -> AnyElement {
1288 RegisteredPrimitiveElement {
1289 registry: self.clone(),
1290 node,
1291 retained_id,
1292 fallback,
1293 dispatcher,
1294 theme,
1295 }
1296 .into_any_element()
1297 }
1298
1299 #[allow(clippy::too_many_lines)]
1300 fn render_instance(
1301 &self,
1302 node: PrimitiveNode,
1303 retained_id: Option<crate::NodeId>,
1304 events: &PrimitiveEventEmitter,
1305 theme: &PrimitiveTheme,
1306 window: &mut Window,
1307 cx: &mut App,
1308 ) -> Result<AnyElement, PrimitiveError> {
1309 let mut inner = self
1310 .inner
1311 .try_borrow_mut()
1312 .map_err(|_| PrimitiveError::Borrowed)?;
1313 let retained_instance = primitive_is_retained(&inner, &node.primitive)?;
1314 if retained_instance && retained_id.is_none() {
1315 return Err(PrimitiveError::MissingRetainedIdentity(node.primitive));
1316 }
1317 let instance_id = retained_instance.then(|| PrimitiveInstanceId {
1318 primitive: node.primitive.clone(),
1319 key: node
1320 .key
1321 .clone()
1322 .expect("retained primitive descriptors require a key"),
1323 node: retained_id.expect("retained primitive renderer supplies NodeId"),
1324 });
1325 let previous = instance_id
1326 .as_ref()
1327 .and_then(|id| inner.mounted.get(id))
1328 .cloned();
1329 let resources = if retained_instance {
1330 Some(
1331 previous
1332 .as_ref()
1333 .and_then(|instance| instance.resources.clone())
1334 .unwrap_or_default(),
1335 )
1336 } else {
1337 None
1338 };
1339 let checkpoint = resources
1340 .as_ref()
1341 .map(PrimitiveResourceScope::checkpoint)
1342 .transpose()?;
1343 let instance = PrimitiveInstance {
1344 id: instance_id.clone(),
1345 node,
1346 resources: resources.clone(),
1347 };
1348 let needs_mount = instance_id.is_some() && previous.is_none();
1349 if needs_mount
1350 && let Some(effect) = inner
1351 .entries
1352 .get(&instance.node.primitive)
1353 .and_then(|entry| entry.descriptor.effect.as_ref())
1354 {
1355 let mounted = inner
1356 .mounted
1357 .keys()
1358 .filter(|id| id.primitive == instance.node.primitive)
1359 .count();
1360 if mounted >= effect.max_instances {
1361 return Err(PrimitiveError::EffectInstanceBudget {
1362 primitive: instance.node.primitive.clone(),
1363 actual: mounted.saturating_add(1),
1364 limit: effect.max_instances,
1365 });
1366 }
1367 }
1368 let entry = inner
1369 .entries
1370 .get_mut(&instance.node.primitive)
1371 .ok_or_else(|| PrimitiveError::Unknown(instance.node.primitive.clone()))?;
1372 if let Some(effect) = &entry.descriptor.effect {
1373 let cost = guard_primitive_panic(&instance.node.primitive, "effect_cost", || {
1374 entry.handler.effect_cost(&instance)
1375 })?;
1376 if cost > effect.max_cost_per_instance {
1377 return Err(PrimitiveError::EffectCostBudget {
1378 primitive: instance.node.primitive.clone(),
1379 actual: cost,
1380 limit: effect.max_cost_per_instance,
1381 });
1382 }
1383 }
1384 let operation = (|| {
1385 if needs_mount {
1386 guard_primitive_panic(&instance.node.primitive, "mount", || {
1387 entry.handler.mount(&instance)
1388 })?
1389 .map_err(|message| PrimitiveError::Handler {
1390 primitive: instance.node.primitive.clone(),
1391 message,
1392 })?;
1393 }
1394 if let Some(previous) = &previous
1395 && previous.node != instance.node
1396 {
1397 guard_primitive_panic(&instance.node.primitive, "update", || {
1398 entry.handler.update(previous, &instance)
1399 })?
1400 .map_err(|message| PrimitiveError::Handler {
1401 primitive: instance.node.primitive.clone(),
1402 message,
1403 })?;
1404 }
1405 guard_primitive_panic(&instance.node.primitive, "render", || {
1406 entry.handler.render(&instance, events, theme, window, cx)
1407 })?
1408 .map_err(|message| PrimitiveError::Handler {
1409 primitive: instance.node.primitive.clone(),
1410 message,
1411 })
1412 })();
1413 let element = match operation {
1414 Ok(element) => element,
1415 Err(error) => {
1416 return Err(rollback_failed_primitive_operation(
1417 entry,
1418 &instance,
1419 needs_mount,
1420 resources.as_ref(),
1421 checkpoint,
1422 error,
1423 ));
1424 }
1425 };
1426 if let Some(id) = instance_id {
1427 inner.mounted.insert(id, instance);
1428 }
1429 Ok(element)
1430 }
1431}
1432
1433fn rollback_failed_primitive_operation(
1434 entry: &mut PrimitiveEntry,
1435 instance: &PrimitiveInstance,
1436 needs_unmount: bool,
1437 resources: Option<&PrimitiveResourceScope>,
1438 checkpoint: Option<u64>,
1439 original: PrimitiveError,
1440) -> PrimitiveError {
1441 let mut rollback_error = None;
1442 if needs_unmount
1443 && let Some(instance_id) = instance.id.as_ref()
1444 && let Err(error) =
1445 guard_primitive_panic(&instance.node.primitive, "failed-mount unmount", || {
1446 entry.handler.unmount(instance_id);
1447 })
1448 {
1449 rollback_error = Some(error);
1450 }
1451 if let (Some(resources), Some(checkpoint)) = (resources, checkpoint)
1452 && let Err(error) = resources.rollback(checkpoint)
1453 && rollback_error.is_none()
1454 {
1455 rollback_error = Some(PrimitiveError::Resource(error));
1456 }
1457 rollback_error.unwrap_or(original)
1458}
1459
1460fn primitive_is_retained(
1461 inner: &PrimitiveRegistryInner,
1462 primitive: &PrimitiveId,
1463) -> Result<bool, PrimitiveError> {
1464 let descriptor = &inner
1465 .entries
1466 .get(primitive)
1467 .ok_or_else(|| PrimitiveError::Unknown(primitive.clone()))?
1468 .descriptor;
1469 Ok(descriptor.lifecycle || !descriptor.state.is_empty())
1470}
1471
1472fn collect_primitive_instances(tree: &crate::RetainedUiTree) -> BTreeSet<PrimitiveInstanceId> {
1473 tree.nodes()
1474 .filter_map(|node| {
1475 Some(PrimitiveInstanceId {
1476 primitive: node.primitive()?.clone(),
1477 key: node.key()?.to_owned(),
1478 node: node.id(),
1479 })
1480 })
1481 .collect()
1482}
1483
1484fn guard_primitive_panic<T>(
1485 primitive: &PrimitiveId,
1486 phase: &'static str,
1487 operation: impl FnOnce() -> T,
1488) -> Result<T, PrimitiveError> {
1489 catch_unwind(AssertUnwindSafe(operation)).map_err(|_| PrimitiveError::Panic {
1490 primitive: primitive.clone(),
1491 phase,
1492 })
1493}
1494
1495#[derive(gpui::IntoElement)]
1496struct RegisteredPrimitiveElement {
1497 registry: PrimitiveRegistry,
1498 node: PrimitiveNode,
1499 retained_id: Option<crate::NodeId>,
1500 fallback: Option<UiNode>,
1501 dispatcher: Option<NodeEventDispatcher>,
1502 theme: PrimitiveTheme,
1503}
1504
1505impl RenderOnce for RegisteredPrimitiveElement {
1506 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1507 let registry = self.registry;
1508 let callbacks = self
1509 .node
1510 .props
1511 .iter()
1512 .filter_map(|(name, value)| {
1513 name.strip_prefix("on_").and_then(|event| match value {
1514 PrimitiveValue::Callback(callback) => {
1515 Some((event.to_owned(), callback.clone()))
1516 }
1517 _ => None,
1518 })
1519 })
1520 .collect();
1521 let events = PrimitiveEventEmitter {
1522 registry: Rc::downgrade(®istry.inner),
1523 primitive: self.node.primitive.clone(),
1524 callbacks,
1525 dispatcher: self.dispatcher,
1526 };
1527 match registry.render_instance(
1528 self.node,
1529 self.retained_id,
1530 &events,
1531 &self.theme,
1532 window,
1533 cx,
1534 ) {
1535 Ok(element) => element,
1536 Err(error) => self.fallback.map_or_else(
1537 || {
1538 div()
1539 .child(format!("Custom primitive error: {error}"))
1540 .into_any_element()
1541 },
1542 |fallback| {
1543 crate::GpuiNodeRenderer::render_with_primitives(
1544 &fallback,
1545 &crate::LiteralColorResolver,
1546 &crate::InteractionState::default(),
1547 ®istry,
1548 )
1549 },
1550 ),
1551 }
1552 }
1553}
1554
1555fn validate_descriptor(descriptor: &PrimitiveDescriptor) -> Result<(), PrimitiveError> {
1556 if !is_pascal_case(&descriptor.export) {
1557 return Err(PrimitiveError::InvalidExport(descriptor.export.clone()));
1558 }
1559 for (name, field) in &descriptor.props {
1560 if !is_identifier(name) {
1561 return Err(PrimitiveError::InvalidPropName(name.clone()));
1562 }
1563 field
1564 .schema
1565 .validate_definition()
1566 .map_err(|source| PrimitiveError::InvalidSchema {
1567 location: format!("prop `{name}`"),
1568 source,
1569 })?;
1570 if let Some(default) = &field.default {
1571 field.schema.validate_ui_value(default).map_err(|source| {
1572 PrimitiveError::InvalidDefault {
1573 prop: name.clone(),
1574 source,
1575 }
1576 })?;
1577 }
1578 }
1579 for (name, event) in &descriptor.events {
1580 event
1581 .payload
1582 .validate_definition()
1583 .map_err(|source| PrimitiveError::InvalidSchema {
1584 location: format!("event `{name}`"),
1585 source,
1586 })?;
1587 let callback = format!("on_{name}");
1588 if !descriptor
1589 .props
1590 .get(&callback)
1591 .is_some_and(|field| schema_accepts_callback(&field.schema))
1592 {
1593 return Err(PrimitiveError::MissingEventCallback {
1594 event: name.clone(),
1595 prop: callback,
1596 });
1597 }
1598 }
1599 ComponentStateSchema::new(descriptor.state.fields().clone())
1600 .map_err(|source| PrimitiveError::InvalidStateSchema(source.to_string()))?;
1601 if let Some(effect) = &descriptor.effect {
1602 if effect.platforms.is_empty() || !effect.platforms.contains(&PrimitivePlatform::current())
1603 {
1604 return Err(PrimitiveError::UnsupportedEffectPlatform {
1605 primitive: descriptor.id.clone(),
1606 platform: PrimitivePlatform::current(),
1607 });
1608 }
1609 if effect.max_instances == 0 || effect.max_cost_per_instance == 0 {
1610 return Err(PrimitiveError::InvalidEffectBudget(descriptor.id.clone()));
1611 }
1612 if !descriptor.lifecycle {
1613 return Err(PrimitiveError::EffectRequiresLifecycle(
1614 descriptor.id.clone(),
1615 ));
1616 }
1617 }
1618 Ok(())
1619}
1620
1621fn schema_accepts_callback(schema: &ValueSchema) -> bool {
1622 matches!(schema, ValueSchema::Callback)
1623 || matches!(schema, ValueSchema::Optional { value } if schema_accepts_callback(value))
1624 || matches!(schema, ValueSchema::OneOf { variants } if variants.iter().any(schema_accepts_callback))
1625}
1626
1627fn convert_props(
1628 schema: &BTreeMap<String, ObjectField>,
1629 values: &Map,
1630 generation: ScriptGeneration,
1631) -> Result<PrimitiveProps, PrimitiveError> {
1632 let mut converted = BTreeMap::new();
1633 for (name, field) in schema {
1634 let value = values
1635 .get(name.as_str())
1636 .cloned()
1637 .or_else(|| field.default.clone().map(UiValue::into_dynamic));
1638 if let Some(value) = value {
1639 converted.insert(
1640 name.clone(),
1641 convert_prop(&field.schema, value, generation).map_err(|source| {
1642 PrimitiveError::PropConversion {
1643 prop: name.clone(),
1644 source,
1645 }
1646 })?,
1647 );
1648 }
1649 }
1650 Ok(PrimitiveProps(converted))
1651}
1652
1653fn convert_prop(
1654 schema: &ValueSchema,
1655 value: Dynamic,
1656 generation: ScriptGeneration,
1657) -> Result<PrimitiveValue, PrimitivePropConversionError> {
1658 match schema {
1659 ValueSchema::Optional { value: inner } if value.is_unit() => {
1660 Ok(PrimitiveValue::Data(UiValue::Null))
1661 }
1662 ValueSchema::Optional { value: inner } => convert_prop(inner, value, generation),
1663 ValueSchema::OneOf { variants } => {
1664 let branch = variants
1665 .iter()
1666 .find(|variant| variant.validate(&value).is_ok())
1667 .expect("validated primitive one_of prop matches one branch");
1668 convert_prop(branch, value, generation)
1669 }
1670 ValueSchema::Node => Ok(PrimitiveValue::Node(Box::new(value.cast::<UiNode>()))),
1671 ValueSchema::Callback if value.is::<FnPtr>() => {
1672 Ok(PrimitiveValue::Callback(UiEventHandler::Script(
1673 ScriptCallback::try_from_fn_ptr(value.cast::<FnPtr>(), generation)?,
1674 )))
1675 }
1676 ValueSchema::Callback => Ok(PrimitiveValue::Callback(UiEventHandler::Native(
1677 value.cast::<crate::NativeHandlerRef>(),
1678 ))),
1679 ValueSchema::Array { items, .. } if matches!(items.as_ref(), ValueSchema::Node) => {
1680 Ok(PrimitiveValue::Nodes(
1681 value
1682 .cast::<Array>()
1683 .into_iter()
1684 .map(Dynamic::cast::<UiNode>)
1685 .collect(),
1686 ))
1687 }
1688 ValueSchema::Style => Ok(PrimitiveValue::Style(Box::new(value.cast::<Style>()))),
1689 ValueSchema::Length => Ok(PrimitiveValue::Length(value.cast::<Length>())),
1690 ValueSchema::Asset => Ok(PrimitiveValue::Asset(value.cast::<AssetId>())),
1691 ValueSchema::Signal => Ok(PrimitiveValue::Signal(value.cast::<crate::NativeSignal>())),
1692 ValueSchema::Ref => Ok(PrimitiveValue::Ref(value.cast::<crate::ElementRef>())),
1693 ValueSchema::Document => Ok(PrimitiveValue::Document(
1694 value.cast::<crate::NativeTextDocument>(),
1695 )),
1696 #[cfg(feature = "charts")]
1697 ValueSchema::ChartData => Ok(PrimitiveValue::ChartData(
1698 value.cast::<crate::NativeChartData>(),
1699 )),
1700 _ => UiValue::from_dynamic(value)
1701 .map(PrimitiveValue::Data)
1702 .map_err(Into::into),
1703 }
1704}
1705
1706#[derive(Debug, Error)]
1707pub enum PrimitivePropConversionError {
1708 #[error(transparent)]
1709 Value(#[from] UiValueError),
1710 #[error(transparent)]
1711 Callback(#[from] crate::ScriptCallbackDefinitionError),
1712}
1713
1714#[derive(Debug, Error)]
1715pub enum PrimitiveError {
1716 #[error("primitive ID `{0}` must be `namespace.snake_case_name`")]
1717 InvalidId(String),
1718 #[error("primitive export `{0}` must be PascalCase")]
1719 InvalidExport(String),
1720 #[error("primitive prop `{0}` must be `snake_case`")]
1721 InvalidPropName(String),
1722 #[error("invalid schema definition for primitive {location}: {source}")]
1723 InvalidSchema {
1724 location: String,
1725 source: SchemaDefinitionError,
1726 },
1727 #[error("invalid primitive state schema: {0}")]
1728 InvalidStateSchema(String),
1729 #[error("primitive registry is already borrowed during rendering")]
1730 Borrowed,
1731 #[error("primitive `{0:?}` is already registered")]
1732 Duplicate(PrimitiveId),
1733 #[error("primitive export `{namespace}::{export}` is already registered")]
1734 DuplicateExport { namespace: String, export: String },
1735 #[error("primitive `{0:?}` is not registered")]
1736 Unknown(PrimitiveId),
1737 #[error("primitive `{0:?}` requires a stable key")]
1738 MissingKey(PrimitiveId),
1739 #[error("primitive `{0:?}` requires a retained NodeId renderer")]
1740 MissingRetainedIdentity(PrimitiveId),
1741 #[error("primitive accessibility action targeted stale instance {0:?}")]
1742 MissingInstance(PrimitiveInstanceId),
1743 #[error("primitive event emitter outlived its registry")]
1744 RegistryReleased,
1745 #[error("props for primitive `{primitive:?}` are invalid: {source}")]
1746 InvalidProps {
1747 primitive: PrimitiveId,
1748 source: SchemaValidationError,
1749 },
1750 #[error("default for primitive prop `{prop}` is invalid: {source}")]
1751 InvalidDefault {
1752 prop: String,
1753 source: SchemaValidationError,
1754 },
1755 #[error("primitive prop `{prop}` cannot cross the runtime boundary: {source}")]
1756 PropConversion {
1757 prop: String,
1758 source: PrimitivePropConversionError,
1759 },
1760 #[error("primitive event `{event}` requires callback prop `{prop}`")]
1761 MissingEventCallback { event: String, prop: String },
1762 #[error("primitive `{primitive:?}` does not declare event `{event}`")]
1763 UnknownEvent {
1764 primitive: PrimitiveId,
1765 event: String,
1766 },
1767 #[error("primitive `{primitive:?}` event `{event}` is invalid: {source}")]
1768 InvalidEvent {
1769 primitive: PrimitiveId,
1770 event: String,
1771 source: SchemaValidationError,
1772 },
1773 #[error("primitive `{primitive:?}` handler failed: {message}")]
1774 Handler {
1775 primitive: PrimitiveId,
1776 message: String,
1777 },
1778 #[error("primitive `{primitive:?}` panicked during {phase}")]
1779 Panic {
1780 primitive: PrimitiveId,
1781 phase: &'static str,
1782 },
1783 #[error("effect primitive `{primitive:?}` does not support {platform:?}")]
1784 UnsupportedEffectPlatform {
1785 primitive: PrimitiveId,
1786 platform: PrimitivePlatform,
1787 },
1788 #[error("effect primitive `{0:?}` must declare positive instance and cost budgets")]
1789 InvalidEffectBudget(PrimitiveId),
1790 #[error("effect primitive `{0:?}` must opt into scoped lifecycle")]
1791 EffectRequiresLifecycle(PrimitiveId),
1792 #[error("effect primitive `{primitive:?}` instance budget exceeded: {actual} > {limit}")]
1793 EffectInstanceBudget {
1794 primitive: PrimitiveId,
1795 actual: usize,
1796 limit: usize,
1797 },
1798 #[error("effect primitive `{primitive:?}` cost budget exceeded: {actual} > {limit}")]
1799 EffectCostBudget {
1800 primitive: PrimitiveId,
1801 actual: usize,
1802 limit: usize,
1803 },
1804 #[error(transparent)]
1805 Resource(#[from] PrimitiveResourceError),
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810 use super::*;
1811 use crate::{ObjectField, StateField};
1812 use std::cell::Cell;
1813
1814 struct TestHandler;
1815
1816 struct TestTheme;
1817
1818 impl ColorResolver for TestTheme {
1819 fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
1820 match color {
1821 ColorValue::Token(token)
1822 if matches!(token.as_str(), "accent" | "selection" | "table.selection") =>
1823 {
1824 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1825 }
1826 _ => None,
1827 }
1828 }
1829
1830 fn resolve_length(&self, length: Length) -> Option<Length> {
1831 match length {
1832 Length::ThemeSpacing(SpacingToken::Xxs) => Some(Length::Pixels(2.0)),
1833 Length::ThemeSpacing(SpacingToken::Sm) => Some(Length::Pixels(6.0)),
1834 _ => None,
1835 }
1836 }
1837
1838 fn resolve_typography(&self, role: &str) -> Option<crate::ResolvedTypography> {
1839 (role == "body").then(|| crate::ResolvedTypography {
1840 family: Some("JetBrains Mono".to_owned()),
1841 fallbacks: vec!["PingFang SC".to_owned()],
1842 size: Length::Pixels(12.0),
1843 line_height: Length::Pixels(16.0),
1844 weight: 400,
1845 })
1846 }
1847 }
1848
1849 impl PrimitiveHandler for TestHandler {
1850 fn render(
1851 &mut self,
1852 _: &PrimitiveInstance,
1853 _: &PrimitiveEventEmitter,
1854 _: &PrimitiveTheme,
1855 _: &mut Window,
1856 _: &mut App,
1857 ) -> Result<AnyElement, String> {
1858 Ok(div().into_any_element())
1859 }
1860 }
1861
1862 #[test]
1863 fn primitive_theme_exposes_only_resolved_semantic_snapshot() {
1864 let theme = PrimitiveTheme::capture(&TestTheme);
1865 assert_eq!(
1866 theme.color("accent"),
1867 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1868 );
1869 assert_eq!(theme.color("unknown"), None);
1870 assert_eq!(
1871 theme.color("selection"),
1872 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1873 );
1874 assert_eq!(
1875 theme.color("table.selection"),
1876 Some(Rgba8::from_rgba_hex(0x1234_56ff))
1877 );
1878 assert_eq!(
1879 theme.resolve_color(&ColorValue::Literal(Rgba8::from_rgba_hex(0xaabb_ccdd))),
1880 Some(Rgba8::from_rgba_hex(0xaabb_ccdd))
1881 );
1882 assert_eq!(
1883 theme.resolve_length(Length::ThemeSpacing(SpacingToken::Sm)),
1884 Some(Length::Pixels(6.0))
1885 );
1886 assert_eq!(
1887 theme.resolve_length(Length::ThemeSpacing(SpacingToken::Xxs)),
1888 Some(Length::Pixels(2.0))
1889 );
1890 assert_eq!(
1891 theme.typography("body").unwrap().family.as_deref(),
1892 Some("JetBrains Mono")
1893 );
1894
1895 let engine = crate::RuntimeEngine::new();
1896 let loaded = crate::load_theme_source(
1897 engine.engine(),
1898 "default_light.rhai",
1899 include_str!("../../../registry/themes/default_light.rhai"),
1900 )
1901 .unwrap();
1902 let captured = PrimitiveTheme::capture(&loaded);
1903 assert_eq!(
1904 captured.color("table.selection"),
1905 loaded.tokens.color("table.selection")
1906 );
1907 assert!(captured.color("table.selection").is_some());
1908 }
1909
1910 fn descriptor() -> PrimitiveDescriptor {
1911 PrimitiveDescriptor {
1912 id: PrimitiveId::parse("my_app.code_editor").unwrap(),
1913 export: "CodeEditor".to_owned(),
1914 props: BTreeMap::from([
1915 (
1916 "value".to_owned(),
1917 ObjectField::required(ValueSchema::string()),
1918 ),
1919 (
1920 "on_change".to_owned(),
1921 ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
1922 ),
1923 ]),
1924 events: BTreeMap::from([(
1925 "change".to_owned(),
1926 EventSchema {
1927 payload: ValueSchema::string(),
1928 },
1929 )]),
1930 state: ComponentStateSchema::new(BTreeMap::from([(
1931 "selection".to_owned(),
1932 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
1933 )]))
1934 .unwrap(),
1935 lifecycle: true,
1936 effect: None,
1937 }
1938 }
1939
1940 #[test]
1941 fn effect_descriptors_require_current_platform_lifecycle_and_budgets() {
1942 let mut descriptor = descriptor();
1943 descriptor.effect = Some(EffectPrimitiveDescriptor {
1944 platforms: BTreeSet::from([PrimitivePlatform::current()]),
1945 max_instances: 8,
1946 max_cost_per_instance: 4_096,
1947 reduced_motion: true,
1948 quality_tiers: true,
1949 });
1950 validate_descriptor(&descriptor).unwrap();
1951 descriptor.lifecycle = false;
1952 assert!(matches!(
1953 validate_descriptor(&descriptor),
1954 Err(PrimitiveError::EffectRequiresLifecycle(_))
1955 ));
1956 }
1957
1958 #[test]
1959 fn custom_primitive_props_and_keys_are_validated() {
1960 let registry = PrimitiveRegistry::new();
1961 let descriptor = descriptor();
1962 let id = descriptor.id.clone();
1963 registry.register(descriptor, TestHandler).unwrap();
1964 assert!(matches!(
1965 registry.create_node(
1966 &id,
1967 None,
1968 &Map::from_iter([("value".into(), Dynamic::from("source"))]),
1969 ScriptGeneration::initial(),
1970 ),
1971 Err(PrimitiveError::MissingKey(_))
1972 ));
1973 registry
1974 .create_node(
1975 &id,
1976 Some("editor".to_owned()),
1977 &Map::from_iter([("value".into(), Dynamic::from("source"))]),
1978 ScriptGeneration::initial(),
1979 )
1980 .unwrap();
1981 }
1982
1983 #[test]
1984 fn custom_primitive_events_are_normalized() {
1985 let registry = PrimitiveRegistry::new();
1986 let descriptor = descriptor();
1987 let id = descriptor.id.clone();
1988 registry.register(descriptor, TestHandler).unwrap();
1989 assert_eq!(
1990 registry
1991 .normalize_event(&id, "change", UiValue::String("new".to_owned()))
1992 .unwrap(),
1993 UiValue::String("new".to_owned())
1994 );
1995 assert!(matches!(
1996 registry.normalize_event(&id, "change", UiValue::Bool(true)),
1997 Err(PrimitiveError::InvalidEvent { .. })
1998 ));
1999 }
2000
2001 #[test]
2002 fn native_panics_are_converted_to_primitive_errors() {
2003 let id = PrimitiveId::parse("my_app.crash").unwrap();
2004 assert!(matches!(
2005 guard_primitive_panic(&id, "render", || panic!("boom")),
2006 Err(PrimitiveError::Panic {
2007 phase: "render",
2008 ..
2009 })
2010 ));
2011 }
2012
2013 #[test]
2014 fn primitive_resource_scope_rolls_back_and_continues_after_cleanup_panic() {
2015 let scope = PrimitiveResourceScope::new();
2016 let retained = Rc::new(Cell::new(0));
2017 let retained_cleanup = Rc::clone(&retained);
2018 scope
2019 .own("retained", move || retained_cleanup.set(1))
2020 .unwrap();
2021 let checkpoint = scope.checkpoint().unwrap();
2022 let order = Rc::new(RefCell::new(Vec::new()));
2023 let first = Rc::clone(&order);
2024 scope
2025 .own("first", move || first.borrow_mut().push(1))
2026 .unwrap();
2027 scope.own("panic", || panic!("cleanup failed")).unwrap();
2028 let last = Rc::clone(&order);
2029 scope
2030 .own("last", move || last.borrow_mut().push(3))
2031 .unwrap();
2032
2033 assert!(matches!(
2034 scope.rollback(checkpoint),
2035 Err(PrimitiveResourceError::CleanupPanic { ref label }) if label == "panic"
2036 ));
2037 assert_eq!(*order.borrow(), vec![3, 1]);
2038 assert_eq!(scope.active_count(), 1);
2039 scope.close().unwrap();
2040 assert_eq!(retained.get(), 1);
2041 }
2042
2043 #[test]
2044 fn successful_tree_cleanup_unmounts_removed_keyed_instances() {
2045 struct UnmountCounter(Rc<Cell<usize>>);
2046 impl PrimitiveHandler for UnmountCounter {
2047 fn render(
2048 &mut self,
2049 _: &PrimitiveInstance,
2050 _: &PrimitiveEventEmitter,
2051 _: &PrimitiveTheme,
2052 _: &mut Window,
2053 _: &mut App,
2054 ) -> Result<AnyElement, String> {
2055 Ok(div().into_any_element())
2056 }
2057
2058 fn unmount(&mut self, _: &PrimitiveInstanceId) {
2059 self.0.set(self.0.get() + 1);
2060 }
2061 }
2062
2063 let registry = PrimitiveRegistry::new();
2064 let descriptor = descriptor();
2065 let node = PrimitiveNode {
2066 primitive: descriptor.id.clone(),
2067 key: Some("editor".to_owned()),
2068 props: PrimitiveProps::new(),
2069 };
2070 let mut tree = crate::RetainedUiTree::new();
2071 tree.reconcile(UiNode::custom(node.clone())).unwrap();
2072 let instance = collect_primitive_instances(&tree)
2073 .into_iter()
2074 .next()
2075 .unwrap();
2076 let unmounted = Rc::new(Cell::new(0));
2077 let cleaned = Rc::new(Cell::new(0));
2078 let resources = PrimitiveResourceScope::new();
2079 let cleanup = Rc::clone(&cleaned);
2080 resources
2081 .own("watcher", move || cleanup.set(cleanup.get() + 1))
2082 .unwrap();
2083 registry
2084 .register(descriptor, UnmountCounter(Rc::clone(&unmounted)))
2085 .unwrap();
2086 registry.inner.borrow_mut().mounted.insert(
2087 instance.clone(),
2088 PrimitiveInstance {
2089 id: Some(instance.clone()),
2090 node,
2091 resources: Some(resources),
2092 },
2093 );
2094 tree.reconcile(UiNode::text("removed")).unwrap();
2095 registry.retain_tree(&tree).unwrap();
2096 assert_eq!(unmounted.get(), 1);
2097 assert_eq!(cleaned.get(), 1);
2098 }
2099
2100 #[test]
2101 fn primitive_identity_uses_retained_node_not_component_local_key() {
2102 let primitive = PrimitiveId::parse("my_app.editor").unwrap();
2103 let branch = |branch: &str| {
2104 UiNode::box_node(vec![UiNode::custom(PrimitiveNode {
2105 primitive: primitive.clone(),
2106 key: Some("editor".to_owned()),
2107 props: PrimitiveProps::new().with(
2108 "branch",
2109 PrimitiveValue::Data(UiValue::String(branch.to_owned())),
2110 ),
2111 })])
2112 .with_key(branch)
2113 };
2114 let mut tree = crate::RetainedUiTree::new();
2115 tree.reconcile(UiNode::box_node(vec![branch("left"), branch("right")]))
2116 .unwrap();
2117 let before = collect_primitive_instances(&tree);
2118 assert_eq!(before.len(), 2);
2119 assert!(before.iter().all(|instance| instance.key() == "editor"));
2120 assert_eq!(
2121 before
2122 .iter()
2123 .map(PrimitiveInstanceId::node)
2124 .collect::<BTreeSet<_>>()
2125 .len(),
2126 2
2127 );
2128
2129 tree.reconcile(UiNode::box_node(vec![branch("right"), branch("left")]))
2130 .unwrap();
2131 assert_eq!(collect_primitive_instances(&tree), before);
2132 }
2133
2134 #[test]
2135 fn primitive_event_emitter_holds_only_a_weak_registry_reference() {
2136 let registry = PrimitiveRegistry::new();
2137 let weak = Rc::downgrade(®istry.inner);
2138 let emitter = PrimitiveEventEmitter {
2139 registry: Rc::downgrade(®istry.inner),
2140 primitive: PrimitiveId::parse("my_app.editor").unwrap(),
2141 callbacks: BTreeMap::new(),
2142 dispatcher: None,
2143 };
2144 assert_eq!(Rc::strong_count(®istry.inner), 1);
2145 drop(registry);
2146 assert!(weak.upgrade().is_none());
2147 drop(emitter);
2148 }
2149}