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 self.run_as(crate::activity::Actor::Person, "app", id, args, window, cx)
362 }
363
364 pub fn run_as(&self, actor: crate::activity::Actor, via: &str, id: &str, args: Value, window: &mut Window, cx: &mut App) -> Result<Value, String> {
367 let Some(c) = self.get(id) else { return Err(format!("no command {id:?}")) };
368 let result = (c.enabled)(cx).and_then(|_| (c.run)(args.clone(), window, cx));
369 crate::activity::command(actor, id, &c.title, &c.group, via, &args, &result);
370 result
371 }
372
373 pub fn bind_keys(&self, context: Option<&str>, cx: &mut App) -> Vec<String> {
377 let mut bindings = Vec::new();
378 let mut bad = Vec::new();
379 let context = match context.map(gpui_kit::KeyBindingContextPredicate::parse).transpose() {
380 Ok(c) => c.map(Rc::new),
381 Err(e) => return vec![format!("context {context:?}: {e}")],
382 };
383 for c in self.all() {
384 let Some(key) = c.key.clone() else { continue };
385 match KeyBinding::load(&key, Box::new(RunCommand { id: c.id.clone() }), context.clone(), false, None, cx) {
386 Ok(b) => bindings.push(b),
387 Err(e) => bad.push(format!("{}: {key}: {e:?}", c.id)),
388 }
389 }
390 cx.bind_keys(bindings);
391 bad
392 }
393
394 pub fn menu_button(&self, id: impl Into<SharedString>, menu: &MenuSpec) -> gpui_kit::AnyElement {
397 let registry = self.clone();
398 let items = Rc::new(menu.items.clone());
399 let mut button = Button::new(id.into()).ghost().small().label(menu.title.clone());
400 if !menu.hint.is_empty() {
401 button = button.tooltip(menu.hint.clone());
402 }
403 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()
405 }
406
407 pub fn fill_menu(&self, mut menu: PopupMenu, items: &[MenuItem], window: &mut Window, cx: &mut Context<PopupMenu>) -> PopupMenu {
409 for item in items {
410 menu = match item {
411 MenuItem::Separator => menu.separator(),
412 MenuItem::Label(text) => menu.label(text.clone()),
413 MenuItem::Submenu { title, icon, items } => {
414 let (registry, items) = (self.clone(), items.clone());
415 menu.submenu_with_icon(icon.map(Icon::new), title.clone(), window, cx, move |m, window, cx| registry.fill_menu(m, &items, window, cx))
416 }
417 MenuItem::Command(id) => match self.get(id) {
418 Some(c) => menu.item(self.menu_item(&c, cx)),
419 None => menu.item(PopupMenuItem::new(format!("{id} (not registered)")).disabled(true)),
421 },
422 MenuItem::Run { title, icon, command, args, checked } => match self.get(command) {
423 Some(c) => menu.item(self.run_item(&c, title, icon.unwrap_or(c.icon), args, *checked, cx)),
424 None => menu.item(PopupMenuItem::new(format!("{title} ({command} not registered)")).disabled(true)),
425 },
426 MenuItem::Provided(id) => {
427 let items = self.provided(id, cx);
428 self.fill_menu(menu, &items, window, cx)
429 }
430 };
431 }
432 menu
433 }
434
435 fn run_item(&self, c: &CommandSpec, title: &SharedString, icon: IconName, args: &Value, checked: bool, cx: &App) -> PopupMenuItem {
437 match (c.enabled)(cx) {
438 Ok(()) => {
439 let (registry, id, args) = (self.clone(), c.id.clone(), args.clone());
440 PopupMenuItem::new(title.clone()).icon(Icon::new(icon)).checked(checked).on_click(move |_, window, cx| {
441 if let Err(e) = registry.run_as(crate::activity::Actor::Person, "menu", &id, args.clone(), window, cx) {
442 eprintln!("{id}: {e}");
443 }
444 })
445 }
446 Err(why) => {
447 let title = title.clone();
448 PopupMenuItem::element(move |_, _| {
449 v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
450 })
451 .icon(Icon::new(icon))
452 .disabled(true)
453 }
454 }
455 }
456
457 fn menu_item(&self, c: &CommandSpec, cx: &App) -> PopupMenuItem {
458 let action = Box::new(RunCommand { id: c.id.clone() });
459 match (c.enabled)(cx) {
460 Ok(()) => {
461 let (registry, id) = (self.clone(), c.id.clone());
462 let checked = c.checked.as_ref().is_some_and(|f| f(cx));
463 PopupMenuItem::new(c.title.clone()).icon(Icon::new(c.icon)).checked(checked).action(action).on_click(move |_, window, cx| {
465 if let Err(e) = registry.run_as(crate::activity::Actor::Person, "menu", &id, Value::Null, window, cx) {
466 eprintln!("{id}: {e}");
467 }
468 })
469 }
470 Err(why) => {
471 let title = c.title.clone();
472 PopupMenuItem::element(move |_, _| {
473 v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
474 })
475 .icon(Icon::new(c.icon))
476 .disabled(true)
477 }
478 }
479 }
480}
481
482pub fn key_label(key: &str) -> String {
485 key.split_whitespace().map(stroke_label).collect::<Vec<_>>().join(" ")
487}
488
489fn stroke_label(stroke: &str) -> String {
490 let mac = cfg!(target_os = "macos");
491 let parts: Vec<&str> = stroke.split('-').collect();
492 let (mods, key) = match parts.as_slice() {
494 [mods @ .., "", ""] => (mods, "-"),
495 [mods @ .., key] => (mods, *key),
496 [] => (&[][..], ""),
497 };
498 let mut out: Vec<String> = mods
499 .iter()
500 .map(|m| match (*m, mac) {
501 ("secondary" | "cmd", true) => "⌘".into(),
502 ("secondary" | "ctrl", false) => "Ctrl".into(),
503 ("ctrl", true) => "⌃".into(),
504 ("cmd", false) => "Super".into(),
505 ("shift", true) => "⇧".into(),
506 ("shift", false) => "Shift".into(),
507 ("alt", true) => "⌥".into(),
508 ("alt", false) => "Alt".into(),
509 (other, _) => other.to_string(),
510 })
511 .collect();
512 let key = match key {
513 k if k.len() == 1 => k.to_uppercase(),
514 k if k.starts_with('f') && k[1..].parse::<u8>().is_ok() => k.to_uppercase(),
515 "escape" => "Esc".into(),
516 k => {
517 let mut c = k.chars();
518 c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
519 }
520 };
521 out.push(key);
522 if mac { out.concat() } else { out.join("+") }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn keys_read_as_people_write_them() {
531 if cfg!(target_os = "macos") {
532 assert_eq!(key_label("secondary-shift-s"), "⌘⇧S");
533 } else {
534 assert_eq!(key_label("secondary-shift-s"), "Ctrl+Shift+S");
535 assert_eq!(key_label("secondary-,"), "Ctrl+,");
536 assert_eq!(key_label("ctrl--"), "Ctrl+-");
537 assert_eq!(key_label("secondary-k secondary-s"), "Ctrl+K Ctrl+S");
538 }
539 assert_eq!(key_label("f3"), "F3");
540 assert_eq!(key_label("escape"), "Esc");
541 }
542
543 #[test]
544 fn menus_list_their_commands() {
545 let m = MenuSpec::new("File", vec![MenuItem::command("file.open"), MenuItem::Separator, MenuItem::submenu("Recent", vec![MenuItem::command("file.recent.1")])]);
546 assert_eq!(m.command_ids(), vec![SharedString::from("file.open"), SharedString::from("file.recent.1")]);
547 }
548
549 #[test]
550 fn a_registry_replaces_a_command_with_the_same_id() {
551 let r = Registry::new(vec![CommandSpec::new("a", "A", IconName::Plus, |_, _| {}), CommandSpec::new("b", "B", IconName::Plus, |_, _| {})]);
552 r.add(CommandSpec::new("a", "A again", IconName::Plus, |_, _| {}));
553 assert_eq!(r.len(), 2);
554 assert_eq!(r.get("a").unwrap().title.as_ref(), "A again");
555 assert!(r.remove("b") && !r.remove("b"));
556 }
557
558 #[derive(serde::Deserialize, JsonSchema)]
559 #[allow(dead_code)]
560 struct Threshold {
561 level: f64,
563 }
564
565 #[test]
566 fn typed_commands_publish_their_schema() {
567 let c = CommandSpec::with_args::<Threshold>("t", "Threshold", IconName::Plus, |a, _, _| Ok(serde_json::json!(a.level)));
568 let schema = c.args.unwrap();
569 assert_eq!(schema["properties"]["level"]["type"], "number");
570 assert_eq!(schema["required"][0], "level");
571 }
572}