use std::cell::RefCell;
use std::rc::Rc;
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{Icon, Sizable as _, v_flex};
use gpui_kit::{Action, App, Context, IntoElement, KeyBinding, ParentElement as _, SharedString, Styled as _, Window, div};
use kitchen_ui::theme::color;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde_json::Value;
pub type RunFn = Rc<dyn Fn(Value, &mut Window, &mut App) -> Result<Value, String>>;
pub type EnabledFn = Rc<dyn Fn(&App) -> Result<(), String>>;
pub type CheckedFn = Rc<dyn Fn(&App) -> bool>;
pub type MenuProvider = Rc<dyn Fn(&App) -> Vec<MenuItem>>;
#[derive(Clone, Debug, PartialEq, Action)]
#[action(namespace = kitchen, no_json)]
pub struct RunCommand {
pub id: SharedString,
}
#[derive(Clone)]
pub struct CommandSpec {
pub id: SharedString,
pub title: SharedString,
pub icon: IconName,
pub hint: SharedString,
pub group: SharedString,
pub key: Option<SharedString>,
pub args: Option<Value>,
pub read_only: bool,
pub enabled: EnabledFn,
pub checked: Option<CheckedFn>,
pub run: RunFn,
}
impl CommandSpec {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
Self::raw(id, title, icon, None, Rc::new(move |_, window, cx| {
run(window, cx);
Ok(Value::Null)
}))
}
pub fn with_args<A: DeserializeOwned + JsonSchema + 'static>(
id: impl Into<SharedString>,
title: impl Into<SharedString>,
icon: IconName,
run: impl Fn(A, &mut Window, &mut App) -> Result<Value, String> + 'static,
) -> Self {
let schema = serde_json::to_value(schemars::schema_for!(A)).ok();
Self::raw(id, title, icon, schema, Rc::new(move |args, window, cx| {
let args = if args.is_null() { Value::Object(Default::default()) } else { args };
let a: A = serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))?;
run(a, window, cx)
}))
}
pub fn raw(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, args: Option<Value>, run: RunFn) -> Self {
Self {
id: id.into(),
title: title.into(),
icon,
hint: SharedString::default(),
group: "Commands".into(),
key: None,
args,
read_only: false,
enabled: Rc::new(|_| Ok(())),
checked: None,
run,
}
}
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = hint.into();
self
}
pub fn group(mut self, group: impl Into<SharedString>) -> Self {
self.group = group.into();
self
}
pub fn key(mut self, key: impl Into<SharedString>) -> Self {
self.key = Some(key.into());
self
}
pub fn read_only(mut self) -> Self {
self.read_only = true;
self
}
pub fn enabled(mut self, enabled: impl Fn(&App) -> Result<(), String> + 'static) -> Self {
self.enabled = Rc::new(enabled);
self
}
pub fn checked(mut self, checked: impl Fn(&App) -> bool + 'static) -> Self {
self.checked = Some(Rc::new(checked));
self
}
pub fn key_label(&self) -> Option<String> {
self.key.as_deref().map(key_label)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MenuItem {
Command(SharedString),
Run {
title: SharedString,
icon: Option<IconName>,
command: SharedString,
args: Value,
checked: bool,
},
Provided(SharedString),
Separator,
Label(SharedString),
Submenu {
title: SharedString,
icon: Option<IconName>,
items: Vec<MenuItem>,
},
}
impl MenuItem {
pub fn command(id: impl Into<SharedString>) -> Self {
MenuItem::Command(id.into())
}
pub fn submenu(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
MenuItem::Submenu { title: title.into(), icon: None, items }
}
pub fn submenu_with_icon(title: impl Into<SharedString>, icon: IconName, items: Vec<MenuItem>) -> Self {
MenuItem::Submenu { title: title.into(), icon: Some(icon), items }
}
pub fn run(title: impl Into<SharedString>, command: impl Into<SharedString>, args: Value) -> Self {
MenuItem::Run { title: title.into(), icon: None, command: command.into(), args, checked: false }
}
pub fn provided(id: impl Into<SharedString>) -> Self {
MenuItem::Provided(id.into())
}
pub fn label(text: impl Into<SharedString>) -> Self {
MenuItem::Label(text.into())
}
pub fn icon(mut self, with: IconName) -> Self {
match &mut self {
MenuItem::Run { icon, .. } | MenuItem::Submenu { icon, .. } => *icon = Some(with),
_ => {}
}
self
}
pub fn checked(mut self, on: bool) -> Self {
if let MenuItem::Run { checked, .. } = &mut self {
*checked = on;
}
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuSpec {
pub title: SharedString,
pub hint: SharedString,
pub items: Vec<MenuItem>,
}
impl MenuSpec {
pub fn new(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
Self { title: title.into(), hint: SharedString::default(), items }
}
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = hint.into();
self
}
pub fn command_ids(&self) -> Vec<SharedString> {
fn walk(items: &[MenuItem], out: &mut Vec<SharedString>) {
for i in items {
match i {
MenuItem::Command(id) | MenuItem::Run { command: id, .. } => out.push(id.clone()),
MenuItem::Submenu { items, .. } => walk(items, out),
_ => {}
}
}
}
let mut out = Vec::new();
walk(&self.items, &mut out);
out
}
}
#[derive(Clone, Default)]
pub struct Registry {
commands: Rc<RefCell<Vec<CommandSpec>>>,
providers: Rc<RefCell<Vec<(SharedString, MenuProvider)>>>,
}
impl Registry {
pub fn new(commands: Vec<CommandSpec>) -> Self {
let r = Self::default();
r.extend(commands);
r
}
pub fn add(&self, command: CommandSpec) {
let mut list = self.commands.borrow_mut();
match list.iter_mut().find(|c| c.id == command.id) {
Some(c) => *c = command,
None => list.push(command),
}
}
pub fn extend(&self, commands: impl IntoIterator<Item = CommandSpec>) {
for c in commands {
self.add(c);
}
}
pub fn remove(&self, id: &str) -> bool {
let mut list = self.commands.borrow_mut();
let n = list.len();
list.retain(|c| c.id != id);
list.len() != n
}
pub fn get(&self, id: &str) -> Option<CommandSpec> {
self.commands.borrow().iter().find(|c| c.id == id).cloned()
}
pub fn all(&self) -> Vec<CommandSpec> {
self.commands.borrow().clone()
}
pub fn len(&self) -> usize {
self.commands.borrow().len()
}
pub fn is_empty(&self) -> bool {
self.commands.borrow().is_empty()
}
pub fn provide(&self, id: impl Into<SharedString>, provider: impl Fn(&App) -> Vec<MenuItem> + 'static) {
let id = id.into();
let mut list = self.providers.borrow_mut();
list.retain(|(p, _)| *p != id);
list.push((id, Rc::new(provider)));
}
pub fn provided(&self, id: &str, cx: &App) -> Vec<MenuItem> {
let provider = self.providers.borrow().iter().find(|(p, _)| p == id).map(|(_, f)| f.clone());
provider.map(|f| f(cx)).unwrap_or_default()
}
pub fn check(&self, id: &str, cx: &App) -> Result<(), String> {
let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
(c.enabled)(cx)
}
pub fn run(&self, id: &str, args: Value, window: &mut Window, cx: &mut App) -> Result<Value, String> {
let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
(c.enabled)(cx)?;
(c.run)(args, window, cx)
}
pub fn bind_keys(&self, context: Option<&str>, cx: &mut App) -> Vec<String> {
let mut bindings = Vec::new();
let mut bad = Vec::new();
let context = match context.map(gpui_kit::KeyBindingContextPredicate::parse).transpose() {
Ok(c) => c.map(Rc::new),
Err(e) => return vec![format!("context {context:?}: {e}")],
};
for c in self.all() {
let Some(key) = c.key.clone() else { continue };
match KeyBinding::load(&key, Box::new(RunCommand { id: c.id.clone() }), context.clone(), false, None, cx) {
Ok(b) => bindings.push(b),
Err(e) => bad.push(format!("{}: {key}: {e:?}", c.id)),
}
}
cx.bind_keys(bindings);
bad
}
pub fn menu_button(&self, id: impl Into<SharedString>, menu: &MenuSpec) -> gpui_kit::AnyElement {
let registry = self.clone();
let items = Rc::new(menu.items.clone());
let mut button = Button::new(id.into()).ghost().small().label(menu.title.clone());
if !menu.hint.is_empty() {
button = button.tooltip(menu.hint.clone());
}
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()
}
pub fn fill_menu(&self, mut menu: PopupMenu, items: &[MenuItem], window: &mut Window, cx: &mut Context<PopupMenu>) -> PopupMenu {
for item in items {
menu = match item {
MenuItem::Separator => menu.separator(),
MenuItem::Label(text) => menu.label(text.clone()),
MenuItem::Submenu { title, icon, items } => {
let (registry, items) = (self.clone(), items.clone());
menu.submenu_with_icon(icon.map(Icon::new), title.clone(), window, cx, move |m, window, cx| registry.fill_menu(m, &items, window, cx))
}
MenuItem::Command(id) => match self.get(id) {
Some(c) => menu.item(self.menu_item(&c, cx)),
None => menu.item(PopupMenuItem::new(format!("{id} (not registered)")).disabled(true)),
},
MenuItem::Run { title, icon, command, args, checked } => match self.get(command) {
Some(c) => menu.item(self.run_item(&c, title, icon.unwrap_or(c.icon), args, *checked, cx)),
None => menu.item(PopupMenuItem::new(format!("{title} ({command} not registered)")).disabled(true)),
},
MenuItem::Provided(id) => {
let items = self.provided(id, cx);
self.fill_menu(menu, &items, window, cx)
}
};
}
menu
}
fn run_item(&self, c: &CommandSpec, title: &SharedString, icon: IconName, args: &Value, checked: bool, cx: &App) -> PopupMenuItem {
match (c.enabled)(cx) {
Ok(()) => {
let (registry, id, args) = (self.clone(), c.id.clone(), args.clone());
PopupMenuItem::new(title.clone()).icon(Icon::new(icon)).checked(checked).on_click(move |_, window, cx| {
if let Err(e) = registry.run(&id, args.clone(), window, cx) {
eprintln!("{id}: {e}");
}
})
}
Err(why) => {
let title = title.clone();
PopupMenuItem::element(move |_, _| {
v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
})
.icon(Icon::new(icon))
.disabled(true)
}
}
}
fn menu_item(&self, c: &CommandSpec, cx: &App) -> PopupMenuItem {
let action = Box::new(RunCommand { id: c.id.clone() });
match (c.enabled)(cx) {
Ok(()) => {
let (registry, id) = (self.clone(), c.id.clone());
let checked = c.checked.as_ref().is_some_and(|f| f(cx));
PopupMenuItem::new(c.title.clone()).icon(Icon::new(c.icon)).checked(checked).action(action).on_click(move |_, window, cx| {
if let Err(e) = registry.run(&id, Value::Null, window, cx) {
eprintln!("{id}: {e}");
}
})
}
Err(why) => {
let title = c.title.clone();
PopupMenuItem::element(move |_, _| {
v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
})
.icon(Icon::new(c.icon))
.disabled(true)
}
}
}
}
pub fn key_label(key: &str) -> String {
key.split_whitespace().map(stroke_label).collect::<Vec<_>>().join(" ")
}
fn stroke_label(stroke: &str) -> String {
let mac = cfg!(target_os = "macos");
let parts: Vec<&str> = stroke.split('-').collect();
let (mods, key) = match parts.as_slice() {
[mods @ .., "", ""] => (mods, "-"),
[mods @ .., key] => (mods, *key),
[] => (&[][..], ""),
};
let mut out: Vec<String> = mods
.iter()
.map(|m| match (*m, mac) {
("secondary" | "cmd", true) => "⌘".into(),
("secondary" | "ctrl", false) => "Ctrl".into(),
("ctrl", true) => "⌃".into(),
("cmd", false) => "Super".into(),
("shift", true) => "⇧".into(),
("shift", false) => "Shift".into(),
("alt", true) => "⌥".into(),
("alt", false) => "Alt".into(),
(other, _) => other.to_string(),
})
.collect();
let key = match key {
k if k.len() == 1 => k.to_uppercase(),
k if k.starts_with('f') && k[1..].parse::<u8>().is_ok() => k.to_uppercase(),
"escape" => "Esc".into(),
k => {
let mut c = k.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
};
out.push(key);
if mac { out.concat() } else { out.join("+") }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_read_as_people_write_them() {
if cfg!(target_os = "macos") {
assert_eq!(key_label("secondary-shift-s"), "⌘⇧S");
} else {
assert_eq!(key_label("secondary-shift-s"), "Ctrl+Shift+S");
assert_eq!(key_label("secondary-,"), "Ctrl+,");
assert_eq!(key_label("ctrl--"), "Ctrl+-");
assert_eq!(key_label("secondary-k secondary-s"), "Ctrl+K Ctrl+S");
}
assert_eq!(key_label("f3"), "F3");
assert_eq!(key_label("escape"), "Esc");
}
#[test]
fn menus_list_their_commands() {
let m = MenuSpec::new("File", vec![MenuItem::command("file.open"), MenuItem::Separator, MenuItem::submenu("Recent", vec![MenuItem::command("file.recent.1")])]);
assert_eq!(m.command_ids(), vec![SharedString::from("file.open"), SharedString::from("file.recent.1")]);
}
#[test]
fn a_registry_replaces_a_command_with_the_same_id() {
let r = Registry::new(vec![CommandSpec::new("a", "A", IconName::Plus, |_, _| {}), CommandSpec::new("b", "B", IconName::Plus, |_, _| {})]);
r.add(CommandSpec::new("a", "A again", IconName::Plus, |_, _| {}));
assert_eq!(r.len(), 2);
assert_eq!(r.get("a").unwrap().title.as_ref(), "A again");
assert!(r.remove("b") && !r.remove("b"));
}
#[derive(serde::Deserialize, JsonSchema)]
#[allow(dead_code)]
struct Threshold {
level: f64,
}
#[test]
fn typed_commands_publish_their_schema() {
let c = CommandSpec::with_args::<Threshold>("t", "Threshold", IconName::Plus, |a, _, _| Ok(serde_json::json!(a.level)));
let schema = c.args.unwrap();
assert_eq!(schema["properties"]["level"]["type"], "number");
assert_eq!(schema["required"][0], "level");
}
}