Skip to main content

gpui_rhai/
host_slot.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::rc::Rc;
4
5use gpui::{
6    AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
7    SharedString, StatefulInteractiveElement, Styled, Window, div,
8};
9use thiserror::Error;
10
11use crate::{
12    ComponentStateSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor, PrimitiveHandler,
13    PrimitiveId, PrimitiveInstance, PrimitiveTheme, PrimitiveValue, RuntimeEngine,
14    ScriptViewExtension, ScriptViewHandle, UiValue, ValueSchema,
15};
16
17type HostSlotFactory = Rc<dyn Fn(&mut Window, &mut App) -> Result<AnyElement, String>>;
18
19/// Host-owned opaque elements exposed to one prepared script view.
20///
21/// The registry is installed as a [`ScriptViewExtension`]. Rhai can place a
22/// registered element through `gpui_rhai::HostSlot`, but it never receives the
23/// element, entity, or lifecycle handle.
24#[derive(Clone, Default)]
25pub struct HostSlotRegistry {
26    slots: BTreeMap<String, HostSlotFactory>,
27}
28
29impl fmt::Debug for HostSlotRegistry {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter
32            .debug_struct("HostSlotRegistry")
33            .field("slots", &self.slots.keys().collect::<Vec<_>>())
34            .finish()
35    }
36}
37
38impl HostSlotRegistry {
39    #[must_use]
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Register one named foreground element factory.
45    ///
46    /// Configure all slots before preparing the script view. The factory runs
47    /// only while GPUI renders the opaque slot on the foreground thread.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`HostSlotError`] for invalid or duplicate names.
52    pub fn register(
53        &mut self,
54        name: impl Into<String>,
55        factory: impl Fn(&mut Window, &mut App) -> Result<AnyElement, String> + 'static,
56    ) -> Result<(), HostSlotError> {
57        let name = name.into();
58        validate_name(&name)?;
59        if self.slots.contains_key(&name) {
60            return Err(HostSlotError::Duplicate(name));
61        }
62        self.slots.insert(name, Rc::new(factory));
63        Ok(())
64    }
65
66    /// Builder form of [`Self::register`].
67    ///
68    /// # Errors
69    ///
70    /// Returns [`HostSlotError`] for invalid or duplicate names.
71    pub fn with_slot(
72        mut self,
73        name: impl Into<String>,
74        factory: impl Fn(&mut Window, &mut App) -> Result<AnyElement, String> + 'static,
75    ) -> Result<Self, HostSlotError> {
76        self.register(name, factory)?;
77        Ok(self)
78    }
79
80    /// Register an existing GPUI entity as opaque slot content.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`HostSlotError`] for invalid or duplicate names.
85    pub fn with_entity<V: Render + 'static>(
86        self,
87        name: impl Into<String>,
88        entity: Entity<V>,
89    ) -> Result<Self, HostSlotError> {
90        self.with_slot(name, move |_, _| Ok(entity.clone().into_any_element()))
91    }
92
93    /// Register an independently mounted script view as opaque slot content.
94    ///
95    /// The Host remains responsible for suspending or disposing the nested
96    /// view. The outer Rhai program receives no nested-view authority. A view
97    /// from another [`crate::ScriptViewHost`] is rendered inside its own Host
98    /// frame boundary; a view already inside the active Host reuses it.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`HostSlotError`] for invalid or duplicate names.
103    pub fn with_script_view(
104        self,
105        name: impl Into<String>,
106        view: ScriptViewHandle,
107    ) -> Result<Self, HostSlotError> {
108        self.with_slot(name, move |_, _| {
109            view.host_slot_item().map_err(|error| error.to_string())
110        })
111    }
112
113    fn factory(&self, name: &str) -> Option<HostSlotFactory> {
114        self.slots.get(name).cloned()
115    }
116}
117
118impl ScriptViewExtension for HostSlotRegistry {
119    fn configure_engine(&self, engine: &mut RuntimeEngine) -> Result<(), String> {
120        engine
121            .register_primitive(
122                host_slot_primitive_descriptor(),
123                HostSlotPrimitiveHandler {
124                    slots: self.clone(),
125                },
126            )
127            .map_err(|error| error.to_string())
128    }
129}
130
131#[derive(Debug, Error, Eq, PartialEq)]
132pub enum HostSlotError {
133    #[error("host slot name `{0}` is invalid")]
134    InvalidName(String),
135    #[error("host slot `{0}` is registered more than once")]
136    Duplicate(String),
137}
138
139fn validate_name(name: &str) -> Result<(), HostSlotError> {
140    if name.is_empty()
141        || name.len() > 128
142        || !name
143            .chars()
144            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
145    {
146        Err(HostSlotError::InvalidName(name.to_owned()))
147    } else {
148        Ok(())
149    }
150}
151
152fn host_slot_primitive_descriptor() -> PrimitiveDescriptor {
153    PrimitiveDescriptor {
154        id: PrimitiveId::parse("gpui_rhai.host_slot")
155            .expect("built-in HostSlot primitive ID is valid"),
156        export: "HostSlot".to_owned(),
157        props: BTreeMap::from([(
158            "name".to_owned(),
159            ObjectField::required(ValueSchema::string()),
160        )]),
161        events: BTreeMap::new(),
162        state: ComponentStateSchema::default(),
163        lifecycle: true,
164        effect: None,
165    }
166}
167
168struct HostSlotPrimitiveHandler {
169    slots: HostSlotRegistry,
170}
171
172impl PrimitiveHandler for HostSlotPrimitiveHandler {
173    fn render(
174        &mut self,
175        instance: &PrimitiveInstance,
176        _: &PrimitiveContext,
177        _: &PrimitiveTheme,
178        window: &mut Window,
179        cx: &mut App,
180    ) -> Result<AnyElement, String> {
181        let Some(PrimitiveValue::Data(UiValue::String(name))) = instance.node.props.get("name")
182        else {
183            return Err("HostSlot.name must be a string".to_owned());
184        };
185        let factory = self
186            .slots
187            .factory(name)
188            .ok_or_else(|| format!("host slot `{name}` is not registered"))?;
189        let child = factory(window, cx)?;
190        let node = instance
191            .id
192            .as_ref()
193            .map(crate::PrimitiveInstanceId::node)
194            .ok_or_else(|| "HostSlot requires retained identity".to_owned())?;
195        let mut boundary = div()
196            .id(SharedString::from(format!("gpui-rhai-host-slot-{node}")))
197            .flex()
198            .flex_col()
199            .size_full()
200            .overflow_hidden()
201            .occlude()
202            .on_any_mouse_down(|_, _, cx| cx.stop_propagation());
203        for button in MouseButton::all() {
204            boundary = boundary.on_mouse_up(button, |_, _, cx| cx.stop_propagation());
205        }
206        Ok(boundary
207            .on_mouse_move(|_, _, cx| cx.stop_propagation())
208            .on_scroll_wheel(|_, _, cx| cx.stop_propagation())
209            .on_click(|_, _, cx| cx.stop_propagation())
210            .child(child)
211            .into_any_element())
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn registry_rejects_invalid_and_duplicate_names() {
221        assert_eq!(
222            HostSlotRegistry::new()
223                .with_slot("bad/name", |_, _| Ok(div().into_any_element()))
224                .unwrap_err(),
225            HostSlotError::InvalidName("bad/name".to_owned())
226        );
227
228        let mut slots = HostSlotRegistry::new();
229        slots
230            .register("content", |_, _| Ok(div().into_any_element()))
231            .unwrap();
232        assert_eq!(
233            slots
234                .register("content", |_, _| Ok(div().into_any_element()))
235                .unwrap_err(),
236            HostSlotError::Duplicate("content".to_owned())
237        );
238    }
239
240    #[test]
241    fn extension_registers_a_keyed_opaque_primitive_constructor() {
242        let slots = HostSlotRegistry::new()
243            .with_slot("content", |_, _| Ok(div().into_any_element()))
244            .unwrap();
245        let mut engine = RuntimeEngine::new();
246        slots.configure_engine(&mut engine).unwrap();
247        let compiled = engine
248            .compile(
249                r#"
250                    fn view() {
251                        gpui_rhai::HostSlot(#{ key: "content", name: "content" })
252                    }
253                "#,
254            )
255            .unwrap();
256        let root = engine.render(&compiled).unwrap();
257        let crate::UiNodeKind::Custom { primitive } = root.kind() else {
258            panic!("HostSlot must remain an ordinary custom primitive");
259        };
260        assert_eq!(primitive.primitive.as_str(), "gpui_rhai.host_slot");
261        assert_eq!(primitive.key.as_deref(), Some("content"));
262        assert_eq!(
263            primitive.props.get("name"),
264            Some(&PrimitiveValue::Data(UiValue::String("content".to_owned())))
265        );
266    }
267}