1use std::cell::RefCell;
10use std::rc::Rc;
11
12use gpui_kit::assets::IconName;
13use gpui_kit::component::button::{Button, ButtonVariants as _};
14use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
15use gpui_kit::component::{Icon, Sizable as _, v_flex};
16use gpui_kit::{Action, App, Context, IntoElement, KeyBinding, ParentElement as _, SharedString, Styled as _, Window, div};
17use kitchen_ui::theme::color;
18use schemars::JsonSchema;
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22pub type RunFn = Rc<dyn Fn(Value, &mut Window, &mut App) -> Result<Value, String>>;
25pub type EnabledFn = Rc<dyn Fn(&App) -> Result<(), String>>;
27pub type CheckedFn = Rc<dyn Fn(&App) -> bool>;
29pub type MenuProvider = Rc<dyn Fn(&App) -> Vec<MenuItem>>;
32
33#[derive(Clone, Debug, PartialEq, Action)]
35#[action(namespace = kitchen, no_json)]
36pub struct RunCommand {
37 pub id: SharedString,
39}
40
41#[derive(Clone)]
43pub struct CommandSpec {
44 pub id: SharedString,
46 pub title: SharedString,
48 pub icon: IconName,
50 pub hint: SharedString,
52 pub group: SharedString,
54 pub key: Option<SharedString>,
56 pub args: Option<Value>,
58 pub read_only: bool,
60 pub enabled: EnabledFn,
62 pub checked: Option<CheckedFn>,
64 pub run: RunFn,
66}
67
68impl CommandSpec {
69 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
71 Self::raw(id, title, icon, None, Rc::new(move |_, window, cx| {
72 run(window, cx);
73 Ok(Value::Null)
74 }))
75 }
76
77 pub fn with_args<A: DeserializeOwned + JsonSchema + 'static>(
80 id: impl Into<SharedString>,
81 title: impl Into<SharedString>,
82 icon: IconName,
83 run: impl Fn(A, &mut Window, &mut App) -> Result<Value, String> + 'static,
84 ) -> Self {
85 let schema = serde_json::to_value(schemars::schema_for!(A)).ok();
86 Self::raw(id, title, icon, schema, Rc::new(move |args, window, cx| {
87 let args = if args.is_null() { Value::Object(Default::default()) } else { args };
89 let a: A = serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))?;
90 run(a, window, cx)
91 }))
92 }
93
94 pub fn raw(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, args: Option<Value>, run: RunFn) -> Self {
96 Self {
97 id: id.into(),
98 title: title.into(),
99 icon,
100 hint: SharedString::default(),
101 group: "Commands".into(),
102 key: None,
103 args,
104 read_only: false,
105 enabled: Rc::new(|_| Ok(())),
106 checked: None,
107 run,
108 }
109 }
110
111 pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
113 self.hint = hint.into();
114 self
115 }
116
117 pub fn group(mut self, group: impl Into<SharedString>) -> Self {
119 self.group = group.into();
120 self
121 }
122
123 pub fn key(mut self, key: impl Into<SharedString>) -> Self {
125 self.key = Some(key.into());
126 self
127 }
128
129 pub fn read_only(mut self) -> Self {
131 self.read_only = true;
132 self
133 }
134
135 pub fn enabled(mut self, enabled: impl Fn(&App) -> Result<(), String> + 'static) -> Self {
137 self.enabled = Rc::new(enabled);
138 self
139 }
140
141 pub fn checked(mut self, checked: impl Fn(&App) -> bool + 'static) -> Self {
143 self.checked = Some(Rc::new(checked));
144 self
145 }
146
147 pub fn key_label(&self) -> Option<String> {
149 self.key.as_deref().map(key_label)
150 }
151}
152
153#[derive(Clone, Debug, PartialEq)]
155pub enum MenuItem {
156 Command(SharedString),
158 Run {
161 title: SharedString,
163 icon: Option<IconName>,
165 command: SharedString,
167 args: Value,
169 checked: bool,
171 },
172 Provided(SharedString),
174 Separator,
176 Label(SharedString),
178 Submenu {
180 title: SharedString,
182 icon: Option<IconName>,
184 items: Vec<MenuItem>,
186 },
187}
188
189impl MenuItem {
190 pub fn command(id: impl Into<SharedString>) -> Self {
192 MenuItem::Command(id.into())
193 }
194
195 pub fn submenu(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
197 MenuItem::Submenu { title: title.into(), icon: None, items }
198 }
199
200 pub fn submenu_with_icon(title: impl Into<SharedString>, icon: IconName, items: Vec<MenuItem>) -> Self {
202 MenuItem::Submenu { title: title.into(), icon: Some(icon), items }
203 }
204
205 pub fn run(title: impl Into<SharedString>, command: impl Into<SharedString>, args: Value) -> Self {
207 MenuItem::Run { title: title.into(), icon: None, command: command.into(), args, checked: false }
208 }
209
210 pub fn provided(id: impl Into<SharedString>) -> Self {
212 MenuItem::Provided(id.into())
213 }
214
215 pub fn label(text: impl Into<SharedString>) -> Self {
217 MenuItem::Label(text.into())
218 }
219
220 pub fn icon(mut self, with: IconName) -> Self {
222 match &mut self {
223 MenuItem::Run { icon, .. } | MenuItem::Submenu { icon, .. } => *icon = Some(with),
224 _ => {}
225 }
226 self
227 }
228
229 pub fn checked(mut self, on: bool) -> Self {
231 if let MenuItem::Run { checked, .. } = &mut self {
232 *checked = on;
233 }
234 self
235 }
236}
237
238#[derive(Clone, Debug, PartialEq)]
240pub struct MenuSpec {
241 pub title: SharedString,
243 pub hint: SharedString,
245 pub items: Vec<MenuItem>,
247}
248
249impl MenuSpec {
250 pub fn new(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
252 Self { title: title.into(), hint: SharedString::default(), items }
253 }
254
255 pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
257 self.hint = hint.into();
258 self
259 }
260
261 pub fn command_ids(&self) -> Vec<SharedString> {
263 fn walk(items: &[MenuItem], out: &mut Vec<SharedString>) {
264 for i in items {
265 match i {
266 MenuItem::Command(id) | MenuItem::Run { command: id, .. } => out.push(id.clone()),
267 MenuItem::Submenu { items, .. } => walk(items, out),
268 _ => {}
269 }
270 }
271 }
272 let mut out = Vec::new();
273 walk(&self.items, &mut out);
274 out
275 }
276}
277
278#[derive(Clone, Default)]
281pub struct Registry {
282 commands: Rc<RefCell<Vec<CommandSpec>>>,
283 providers: Rc<RefCell<Vec<(SharedString, MenuProvider)>>>,
284}
285
286impl Registry {
287 pub fn new(commands: Vec<CommandSpec>) -> Self {
289 let r = Self::default();
290 r.extend(commands);
291 r
292 }
293
294 pub fn add(&self, command: CommandSpec) {
296 let mut list = self.commands.borrow_mut();
297 match list.iter_mut().find(|c| c.id == command.id) {
298 Some(c) => *c = command,
299 None => list.push(command),
300 }
301 }
302
303 pub fn extend(&self, commands: impl IntoIterator<Item = CommandSpec>) {
305 for c in commands {
306 self.add(c);
307 }
308 }
309
310 pub fn remove(&self, id: &str) -> bool {
312 let mut list = self.commands.borrow_mut();
313 let n = list.len();
314 list.retain(|c| c.id != id);
315 list.len() != n
316 }
317
318 pub fn get(&self, id: &str) -> Option<CommandSpec> {
320 self.commands.borrow().iter().find(|c| c.id == id).cloned()
321 }
322
323 pub fn all(&self) -> Vec<CommandSpec> {
325 self.commands.borrow().clone()
326 }
327
328 pub fn len(&self) -> usize {
330 self.commands.borrow().len()
331 }
332
333 pub fn is_empty(&self) -> bool {
335 self.commands.borrow().is_empty()
336 }
337
338 pub fn provide(&self, id: impl Into<SharedString>, provider: impl Fn(&App) -> Vec<MenuItem> + 'static) {
341 let id = id.into();
342 let mut list = self.providers.borrow_mut();
343 list.retain(|(p, _)| *p != id);
344 list.push((id, Rc::new(provider)));
345 }
346
347 pub fn provided(&self, id: &str, cx: &App) -> Vec<MenuItem> {
349 let provider = self.providers.borrow().iter().find(|(p, _)| p == id).map(|(_, f)| f.clone());
350 provider.map(|f| f(cx)).unwrap_or_default()
351 }
352
353 pub fn check(&self, id: &str, cx: &App) -> Result<(), String> {
355 let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
356 (c.enabled)(cx)
357 }
358
359 pub fn run(&self, id: &str, args: Value, window: &mut Window, cx: &mut App) -> Result<Value, String> {
361 let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
362 (c.enabled)(cx)?;
363 (c.run)(args, window, cx)
364 }
365
366 pub fn bind_keys(&self, context: Option<&str>, cx: &mut App) -> Vec<String> {
370 let mut bindings = Vec::new();
371 let mut bad = Vec::new();
372 let context = match context.map(gpui_kit::KeyBindingContextPredicate::parse).transpose() {
373 Ok(c) => c.map(Rc::new),
374 Err(e) => return vec![format!("context {context:?}: {e}")],
375 };
376 for c in self.all() {
377 let Some(key) = c.key.clone() else { continue };
378 match KeyBinding::load(&key, Box::new(RunCommand { id: c.id.clone() }), context.clone(), false, None, cx) {
379 Ok(b) => bindings.push(b),
380 Err(e) => bad.push(format!("{}: {key}: {e:?}", c.id)),
381 }
382 }
383 cx.bind_keys(bindings);
384 bad
385 }
386
387 pub fn menu_button(&self, id: impl Into<SharedString>, menu: &MenuSpec) -> gpui_kit::AnyElement {
390 let registry = self.clone();
391 let items = Rc::new(menu.items.clone());
392 let mut button = Button::new(id.into()).ghost().small().label(menu.title.clone());
393 if !menu.hint.is_empty() {
394 button = button.tooltip(menu.hint.clone());
395 }
396 button.dropdown_menu(move |m, window, cx| registry.fill_menu(m.min_w(gpui_kit::px(240.)).scrollable(true).max_h(gpui_kit::px(560.)), &items, window, cx)).into_any_element()
398 }
399
400 pub fn fill_menu(&self, mut menu: PopupMenu, items: &[MenuItem], window: &mut Window, cx: &mut Context<PopupMenu>) -> PopupMenu {
402 for item in items {
403 menu = match item {
404 MenuItem::Separator => menu.separator(),
405 MenuItem::Label(text) => menu.label(text.clone()),
406 MenuItem::Submenu { title, icon, items } => {
407 let (registry, items) = (self.clone(), items.clone());
408 menu.submenu_with_icon(icon.map(Icon::new), title.clone(), window, cx, move |m, window, cx| registry.fill_menu(m, &items, window, cx))
409 }
410 MenuItem::Command(id) => match self.get(id) {
411 Some(c) => menu.item(self.menu_item(&c, cx)),
412 None => menu.item(PopupMenuItem::new(format!("{id} (not registered)")).disabled(true)),
414 },
415 MenuItem::Run { title, icon, command, args, checked } => match self.get(command) {
416 Some(c) => menu.item(self.run_item(&c, title, icon.unwrap_or(c.icon), args, *checked, cx)),
417 None => menu.item(PopupMenuItem::new(format!("{title} ({command} not registered)")).disabled(true)),
418 },
419 MenuItem::Provided(id) => {
420 let items = self.provided(id, cx);
421 self.fill_menu(menu, &items, window, cx)
422 }
423 };
424 }
425 menu
426 }
427
428 fn run_item(&self, c: &CommandSpec, title: &SharedString, icon: IconName, args: &Value, checked: bool, cx: &App) -> PopupMenuItem {
430 match (c.enabled)(cx) {
431 Ok(()) => {
432 let (registry, id, args) = (self.clone(), c.id.clone(), args.clone());
433 PopupMenuItem::new(title.clone()).icon(Icon::new(icon)).checked(checked).on_click(move |_, window, cx| {
434 if let Err(e) = registry.run(&id, args.clone(), window, cx) {
435 eprintln!("{id}: {e}");
436 }
437 })
438 }
439 Err(why) => {
440 let title = title.clone();
441 PopupMenuItem::element(move |_, _| {
442 v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
443 })
444 .icon(Icon::new(icon))
445 .disabled(true)
446 }
447 }
448 }
449
450 fn menu_item(&self, c: &CommandSpec, cx: &App) -> PopupMenuItem {
451 let action = Box::new(RunCommand { id: c.id.clone() });
452 match (c.enabled)(cx) {
453 Ok(()) => {
454 let (registry, id) = (self.clone(), c.id.clone());
455 let checked = c.checked.as_ref().is_some_and(|f| f(cx));
456 PopupMenuItem::new(c.title.clone()).icon(Icon::new(c.icon)).checked(checked).action(action).on_click(move |_, window, cx| {
458 if let Err(e) = registry.run(&id, Value::Null, window, cx) {
459 eprintln!("{id}: {e}");
460 }
461 })
462 }
463 Err(why) => {
464 let title = c.title.clone();
465 PopupMenuItem::element(move |_, _| {
466 v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
467 })
468 .icon(Icon::new(c.icon))
469 .disabled(true)
470 }
471 }
472 }
473}
474
475pub fn key_label(key: &str) -> String {
478 key.split_whitespace().map(stroke_label).collect::<Vec<_>>().join(" ")
480}
481
482fn stroke_label(stroke: &str) -> String {
483 let mac = cfg!(target_os = "macos");
484 let parts: Vec<&str> = stroke.split('-').collect();
485 let (mods, key) = match parts.as_slice() {
487 [mods @ .., "", ""] => (mods, "-"),
488 [mods @ .., key] => (mods, *key),
489 [] => (&[][..], ""),
490 };
491 let mut out: Vec<String> = mods
492 .iter()
493 .map(|m| match (*m, mac) {
494 ("secondary" | "cmd", true) => "⌘".into(),
495 ("secondary" | "ctrl", false) => "Ctrl".into(),
496 ("ctrl", true) => "⌃".into(),
497 ("cmd", false) => "Super".into(),
498 ("shift", true) => "⇧".into(),
499 ("shift", false) => "Shift".into(),
500 ("alt", true) => "⌥".into(),
501 ("alt", false) => "Alt".into(),
502 (other, _) => other.to_string(),
503 })
504 .collect();
505 let key = match key {
506 k if k.len() == 1 => k.to_uppercase(),
507 k if k.starts_with('f') && k[1..].parse::<u8>().is_ok() => k.to_uppercase(),
508 "escape" => "Esc".into(),
509 k => {
510 let mut c = k.chars();
511 c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
512 }
513 };
514 out.push(key);
515 if mac { out.concat() } else { out.join("+") }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn keys_read_as_people_write_them() {
524 if cfg!(target_os = "macos") {
525 assert_eq!(key_label("secondary-shift-s"), "⌘⇧S");
526 } else {
527 assert_eq!(key_label("secondary-shift-s"), "Ctrl+Shift+S");
528 assert_eq!(key_label("secondary-,"), "Ctrl+,");
529 assert_eq!(key_label("ctrl--"), "Ctrl+-");
530 assert_eq!(key_label("secondary-k secondary-s"), "Ctrl+K Ctrl+S");
531 }
532 assert_eq!(key_label("f3"), "F3");
533 assert_eq!(key_label("escape"), "Esc");
534 }
535
536 #[test]
537 fn menus_list_their_commands() {
538 let m = MenuSpec::new("File", vec![MenuItem::command("file.open"), MenuItem::Separator, MenuItem::submenu("Recent", vec![MenuItem::command("file.recent.1")])]);
539 assert_eq!(m.command_ids(), vec![SharedString::from("file.open"), SharedString::from("file.recent.1")]);
540 }
541
542 #[test]
543 fn a_registry_replaces_a_command_with_the_same_id() {
544 let r = Registry::new(vec![CommandSpec::new("a", "A", IconName::Plus, |_, _| {}), CommandSpec::new("b", "B", IconName::Plus, |_, _| {})]);
545 r.add(CommandSpec::new("a", "A again", IconName::Plus, |_, _| {}));
546 assert_eq!(r.len(), 2);
547 assert_eq!(r.get("a").unwrap().title.as_ref(), "A again");
548 assert!(r.remove("b") && !r.remove("b"));
549 }
550
551 #[derive(serde::Deserialize, JsonSchema)]
552 #[allow(dead_code)]
553 struct Threshold {
554 level: f64,
556 }
557
558 #[test]
559 fn typed_commands_publish_their_schema() {
560 let c = CommandSpec::with_args::<Threshold>("t", "Threshold", IconName::Plus, |a, _, _| Ok(serde_json::json!(a.level)));
561 let schema = c.args.unwrap();
562 assert_eq!(schema["properties"]["level"]["type"], "number");
563 assert_eq!(schema["required"][0], "level");
564 }
565}