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 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 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 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 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 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 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}