Skip to main content

gpui_rhai/
action.rs

1use std::collections::BTreeMap;
2use std::rc::Rc;
3
4use gpui::{DummyKeyboardMapper, KeyBinding, KeyBindingContextPredicate};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{ScriptCallback, UiValue};
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11pub struct ActionId(String);
12
13impl ActionId {
14    /// Parse a namespaced semantic action identifier such as `document.save`.
15    ///
16    /// # Errors
17    ///
18    /// Returns [`ActionError::InvalidId`] unless both segments are non-empty
19    /// `snake_case` identifiers.
20    pub fn parse(value: impl Into<String>) -> Result<Self, ActionError> {
21        let value = value.into();
22        if value
23            .split_once('.')
24            .is_some_and(|(namespace, action)| is_identifier(namespace) && is_identifier(action))
25        {
26            Ok(Self(value))
27        } else {
28            Err(ActionError::InvalidId(value))
29        }
30    }
31
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38fn is_identifier(value: &str) -> bool {
39    !value.is_empty()
40        && !value.starts_with('_')
41        && !value.ends_with('_')
42        && !value.contains("__")
43        && value.chars().all(|character| {
44            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
45        })
46}
47
48#[derive(Clone, Debug)]
49struct ActionEntry {
50    callback: ScriptCallback,
51    enabled: bool,
52}
53
54#[derive(Clone, Debug, Default)]
55pub struct ActionRegistry {
56    actions: BTreeMap<ActionId, ActionEntry>,
57}
58
59impl ActionRegistry {
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Register a semantic action and its Rhai callback.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`ActionError::Duplicate`] when the ID already exists.
70    pub fn register(&mut self, id: ActionId, callback: ScriptCallback) -> Result<(), ActionError> {
71        if self.actions.contains_key(&id) {
72            return Err(ActionError::Duplicate(id));
73        }
74        self.actions.insert(
75            id,
76            ActionEntry {
77                callback,
78                enabled: true,
79            },
80        );
81        Ok(())
82    }
83
84    pub fn register_or_replace(&mut self, id: ActionId, callback: ScriptCallback) {
85        self.actions.insert(
86            id,
87            ActionEntry {
88                callback,
89                enabled: true,
90            },
91        );
92    }
93
94    pub fn remove_component_scope(&mut self, component: &crate::ComponentInstancePath) {
95        self.actions.retain(|_, entry| {
96            !entry
97                .callback
98                .component()
99                .is_some_and(|path| path.is_within(component))
100        });
101    }
102
103    /// Change whether an action may dispatch.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`ActionError::Unknown`] when the ID is not registered.
108    pub fn set_enabled(&mut self, id: &ActionId, enabled: bool) -> Result<(), ActionError> {
109        self.actions
110            .get_mut(id)
111            .ok_or_else(|| ActionError::Unknown(id.clone()))?
112            .enabled = enabled;
113        Ok(())
114    }
115
116    /// Resolve a semantic action into a callback invocation.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ActionError::Unknown`] or [`ActionError::Disabled`].
121    pub fn dispatch(
122        &self,
123        id: &ActionId,
124        payload: UiValue,
125    ) -> Result<ActionInvocation, ActionError> {
126        let entry = self
127            .actions
128            .get(id)
129            .ok_or_else(|| ActionError::Unknown(id.clone()))?;
130        if !entry.enabled {
131            return Err(ActionError::Disabled(id.clone()));
132        }
133        Ok(ActionInvocation {
134            id: id.clone(),
135            callback: entry.callback.clone(),
136            payload,
137        })
138    }
139}
140
141#[derive(Clone, Debug)]
142pub struct ActionInvocation {
143    pub id: ActionId,
144    pub callback: ScriptCallback,
145    pub payload: UiValue,
146}
147
148#[derive(Clone, Debug, PartialEq, gpui::Action)]
149#[action(namespace = gpui_rhai, no_json)]
150pub struct DispatchScriptAction {
151    pub id: String,
152}
153
154#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
155pub struct KeyBindingSpec {
156    pub keystrokes: String,
157    pub action: ActionId,
158    pub context: Option<String>,
159}
160
161impl KeyBindingSpec {
162    /// Validate a key binding without panicking on malformed user input.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`ActionError::InvalidKeystroke`] or
167    /// [`ActionError::InvalidContext`].
168    pub fn new(
169        keystrokes: impl Into<String>,
170        action: ActionId,
171        context: Option<String>,
172    ) -> Result<Self, ActionError> {
173        let keystrokes = keystrokes.into();
174        if keystrokes.trim().is_empty() {
175            return Err(ActionError::InvalidKeystroke(
176                "key binding cannot be empty".to_owned(),
177            ));
178        }
179        for keystroke in keystrokes.split_whitespace() {
180            gpui::Keystroke::parse(keystroke)
181                .map_err(|error| ActionError::InvalidKeystroke(error.to_string()))?;
182        }
183        if let Some(context) = &context {
184            KeyBindingContextPredicate::parse(context)
185                .map_err(|error| ActionError::InvalidContext(error.to_string()))?;
186        }
187        Ok(Self {
188            keystrokes,
189            action,
190            context,
191        })
192    }
193
194    /// Convert the validated specification into a GPUI key binding.
195    ///
196    /// # Errors
197    ///
198    /// Returns an [`ActionError`] if data was deserialized without passing
199    /// through [`KeyBindingSpec::new`] and is invalid.
200    pub fn to_gpui(&self) -> Result<KeyBinding, ActionError> {
201        let context = self
202            .context
203            .as_deref()
204            .map(KeyBindingContextPredicate::parse)
205            .transpose()
206            .map_err(|error| ActionError::InvalidContext(error.to_string()))?
207            .map(Rc::new);
208        KeyBinding::load(
209            &self.keystrokes,
210            Box::new(DispatchScriptAction {
211                id: self.action.as_str().to_owned(),
212            }),
213            context,
214            false,
215            None,
216            &DummyKeyboardMapper,
217        )
218        .map_err(|error| ActionError::InvalidKeystroke(error.to_string()))
219    }
220}
221
222#[derive(Clone, Debug, Error, Eq, PartialEq)]
223pub enum ActionError {
224    #[error("action ID `{0}` must be `namespace.snake_case_action`")]
225    InvalidId(String),
226    #[error("action `{0:?}` is already registered")]
227    Duplicate(ActionId),
228    #[error("action `{0:?}` is not registered")]
229    Unknown(ActionId),
230    #[error("action `{0:?}` is disabled")]
231    Disabled(ActionId),
232    #[error("invalid key binding: {0}")]
233    InvalidKeystroke(String),
234    #[error("invalid key context: {0}")]
235    InvalidContext(String),
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::RuntimeEngine;
242
243    fn callback() -> ScriptCallback {
244        let mut runtime = RuntimeEngine::new();
245        let compiled = runtime
246            .compile(
247                r#"
248                    fn view() { text("action") }
249                    fn save(payload) { payload }
250                "#,
251            )
252            .unwrap();
253        runtime.callback(&compiled, "save").unwrap()
254    }
255
256    #[test]
257    fn disabled_actions_do_not_dispatch() {
258        let id = ActionId::parse("document.save").unwrap();
259        let mut registry = ActionRegistry::new();
260        registry.register(id.clone(), callback()).unwrap();
261        registry.set_enabled(&id, false).unwrap();
262        assert!(matches!(
263            registry.dispatch(&id, UiValue::Null),
264            Err(ActionError::Disabled(_))
265        ));
266    }
267
268    #[test]
269    fn key_bindings_validate_and_convert_to_gpui() {
270        let binding = KeyBindingSpec::new(
271            "cmd-s",
272            ActionId::parse("document.save").unwrap(),
273            Some("Editor && mode == full".to_owned()),
274        )
275        .unwrap();
276        binding.to_gpui().unwrap();
277
278        assert!(matches!(
279            KeyBindingSpec::new("", ActionId::parse("document.save").unwrap(), None,),
280            Err(ActionError::InvalidKeystroke(_))
281        ));
282    }
283}