use crate::{
bridge_text_input::{
Copy as TextCopy, Cut as TextCut, Paste as TextPaste, SelectAll as TextSelectAll,
},
etf_decode,
};
use eetf::Term;
use gpui::{
Action, App, KeyBinding, Keystroke, Menu, MenuItem, OsAction, SharedString, SystemMenuType,
};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MenuSpec {
pub label: String,
pub items: Vec<MenuItemSpec>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MenuItemSpec {
Separator,
Submenu(MenuSpec),
SystemMenu(MenuSystemMenuSpec),
Action(MenuActionSpec),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MenuSystemMenuSpec {
pub label: String,
pub menu_type: MenuSystemMenuType,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MenuSystemMenuType {
Services,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MenuActionSpec {
pub id: String,
pub label: String,
pub callback: Option<String>,
pub shortcut: Option<String>,
pub enabled: bool,
pub os_action: Option<MenuOsAction>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MenuOsAction {
Cut,
Copy,
Paste,
SelectAll,
Undo,
Redo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GuppyMenuAction {
pub id: String,
pub callback: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GuppyDockMenuAction {
pub id: String,
pub callback: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct GuppyDisabledMenuAction {
id: String,
callback: Option<String>,
}
impl MenuSpec {
pub fn decode_etf(bytes: &[u8]) -> Result<Vec<Self>, String> {
let term = etf_decode::decode_term(bytes)?;
decode_menus(&term)
}
}
impl MenuItemSpec {
pub fn decode_list_etf(bytes: &[u8]) -> Result<Vec<Self>, String> {
let term = etf_decode::decode_term(bytes)?;
decode_menu_items(&term)
}
}
pub(crate) fn bind_menu_action(cx: &mut App) {
cx.on_action(|action: &GuppyMenuAction, _cx| {
let _ = crate::send_menu_action_event(&action.id, &action.callback);
});
cx.on_action(|action: &GuppyDockMenuAction, _cx| {
let _ = crate::send_dock_menu_action_event(&action.id, &action.callback);
});
}
pub(crate) fn install_menus(cx: &mut App, menus: Vec<MenuSpec>) {
cx.clear_key_bindings();
crate::bridge_text_input::bind_keys(cx);
crate::bridge_view::bind_focus_keys(cx);
let key_bindings = key_bindings(&menus);
if !key_bindings.is_empty() {
cx.bind_keys(key_bindings);
}
cx.set_menus(to_gpui_menus(menus));
}
pub(crate) fn to_gpui_menus(menus: Vec<MenuSpec>) -> Vec<Menu> {
menus.into_iter().map(to_gpui_menu).collect()
}
pub(crate) fn to_gpui_dock_menu_items(items: Vec<MenuItemSpec>) -> Vec<MenuItem> {
items.into_iter().map(to_gpui_dock_menu_item).collect()
}
pub(crate) fn key_bindings(menus: &[MenuSpec]) -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for menu in menus {
collect_key_bindings(&menu.items, &mut bindings);
}
bindings
}
fn collect_key_bindings(items: &[MenuItemSpec], bindings: &mut Vec<KeyBinding>) {
for item in items {
match item {
MenuItemSpec::Action(action) if action.enabled => {
if let Some(shortcut) = action.shortcut.as_deref() {
match (action.os_action, action.callback.as_ref()) {
(Some(os_action), _) => {
push_os_action_key_binding(shortcut, os_action, bindings)
}
(None, Some(callback)) => bindings.push(KeyBinding::new(
shortcut,
GuppyMenuAction {
id: action.id.clone(),
callback: callback.clone(),
},
None,
)),
(None, None) => {}
}
}
}
MenuItemSpec::Submenu(menu) => collect_key_bindings(&menu.items, bindings),
MenuItemSpec::SystemMenu(_) => {}
_ => {}
}
}
}
fn push_os_action_key_binding(
shortcut: &str,
os_action: MenuOsAction,
bindings: &mut Vec<KeyBinding>,
) {
match os_action {
MenuOsAction::Cut => bindings.push(KeyBinding::new(shortcut, TextCut, None)),
MenuOsAction::Copy => bindings.push(KeyBinding::new(shortcut, TextCopy, None)),
MenuOsAction::Paste => bindings.push(KeyBinding::new(shortcut, TextPaste, None)),
MenuOsAction::SelectAll => bindings.push(KeyBinding::new(shortcut, TextSelectAll, None)),
MenuOsAction::Undo | MenuOsAction::Redo => {}
}
}
fn to_gpui_menu(menu: MenuSpec) -> Menu {
Menu {
name: SharedString::from(menu.label),
items: menu.items.into_iter().map(to_gpui_menu_item).collect(),
}
}
fn to_gpui_menu_item(item: MenuItemSpec) -> MenuItem {
match item {
MenuItemSpec::Separator => MenuItem::separator(),
MenuItemSpec::Submenu(menu) => MenuItem::submenu(to_gpui_menu(menu)),
MenuItemSpec::SystemMenu(menu) => to_gpui_system_menu_item(menu),
MenuItemSpec::Action(action) => to_gpui_action_item(action),
}
}
fn to_gpui_dock_menu_item(item: MenuItemSpec) -> MenuItem {
match item {
MenuItemSpec::Separator => MenuItem::separator(),
MenuItemSpec::Submenu(menu) => MenuItem::submenu(Menu {
name: SharedString::from(menu.label),
items: menu.items.into_iter().map(to_gpui_dock_menu_item).collect(),
}),
MenuItemSpec::SystemMenu(menu) => to_gpui_system_menu_item(menu),
MenuItemSpec::Action(action) => to_gpui_dock_action_item(action),
}
}
fn to_gpui_system_menu_item(menu: MenuSystemMenuSpec) -> MenuItem {
MenuItem::os_submenu(menu.label, to_gpui_system_menu_type(menu.menu_type))
}
fn to_gpui_system_menu_type(menu_type: MenuSystemMenuType) -> SystemMenuType {
match menu_type {
MenuSystemMenuType::Services => SystemMenuType::Services,
}
}
fn to_gpui_action_item(action: MenuActionSpec) -> MenuItem {
to_gpui_callback_action_item(action, |id, callback| GuppyMenuAction { id, callback })
}
fn to_gpui_dock_action_item(action: MenuActionSpec) -> MenuItem {
to_gpui_callback_action_item(action, |id, callback| GuppyDockMenuAction { id, callback })
}
fn to_gpui_callback_action_item<A>(
action: MenuActionSpec,
build_callback_action: impl FnOnce(String, String) -> A,
) -> MenuItem
where
A: Action + 'static,
{
if !action.enabled {
return disabled_action_item(action);
}
match action.os_action {
Some(MenuOsAction::Cut) => MenuItem::os_action(action.label, TextCut, OsAction::Cut),
Some(MenuOsAction::Copy) => MenuItem::os_action(action.label, TextCopy, OsAction::Copy),
Some(MenuOsAction::Paste) => MenuItem::os_action(action.label, TextPaste, OsAction::Paste),
Some(MenuOsAction::SelectAll) => {
MenuItem::os_action(action.label, TextSelectAll, OsAction::SelectAll)
}
Some(MenuOsAction::Undo) | Some(MenuOsAction::Redo) => disabled_action_item(action),
None => {
let MenuActionSpec {
id,
label,
callback,
..
} = action;
match callback {
Some(callback) => MenuItem::action(label, build_callback_action(id, callback)),
None => MenuItem::action(label, GuppyDisabledMenuAction { id, callback }),
}
}
}
}
fn disabled_action_item(action: MenuActionSpec) -> MenuItem {
match action.os_action {
Some(os_action) => MenuItem::os_action(
action.label,
GuppyDisabledMenuAction {
id: action.id,
callback: action.callback,
},
to_gpui_os_action(os_action),
),
None => MenuItem::action(
action.label,
GuppyDisabledMenuAction {
id: action.id,
callback: action.callback,
},
),
}
}
fn to_gpui_os_action(action: MenuOsAction) -> OsAction {
match action {
MenuOsAction::Cut => OsAction::Cut,
MenuOsAction::Copy => OsAction::Copy,
MenuOsAction::Paste => OsAction::Paste,
MenuOsAction::SelectAll => OsAction::SelectAll,
MenuOsAction::Undo => OsAction::Undo,
MenuOsAction::Redo => OsAction::Redo,
}
}
impl Action for GuppyMenuAction {
fn boxed_clone(&self) -> Box<dyn Action> {
Box::new(self.clone())
}
fn partial_eq(&self, action: &dyn Action) -> bool {
action.as_any().downcast_ref::<Self>() == Some(self)
}
fn name(&self) -> &'static str {
Self::name_for_type()
}
fn name_for_type() -> &'static str
where
Self: Sized,
{
"guppy::MenuAction"
}
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn Action>>
where
Self: Sized,
{
Err(anyhow::anyhow!(
"Guppy menu actions are data-only native actions"
))
}
}
impl Action for GuppyDockMenuAction {
fn boxed_clone(&self) -> Box<dyn Action> {
Box::new(self.clone())
}
fn partial_eq(&self, action: &dyn Action) -> bool {
action.as_any().downcast_ref::<Self>() == Some(self)
}
fn name(&self) -> &'static str {
Self::name_for_type()
}
fn name_for_type() -> &'static str
where
Self: Sized,
{
"guppy::DockMenuAction"
}
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn Action>>
where
Self: Sized,
{
Err(anyhow::anyhow!(
"Guppy dock menu actions are data-only native actions"
))
}
}
impl Action for GuppyDisabledMenuAction {
fn boxed_clone(&self) -> Box<dyn Action> {
Box::new(self.clone())
}
fn partial_eq(&self, action: &dyn Action) -> bool {
action.as_any().downcast_ref::<Self>() == Some(self)
}
fn name(&self) -> &'static str {
Self::name_for_type()
}
fn name_for_type() -> &'static str
where
Self: Sized,
{
"guppy::DisabledMenuAction"
}
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn Action>>
where
Self: Sized,
{
Err(anyhow::anyhow!(
"Guppy disabled menu actions are not buildable"
))
}
}
fn decode_menus(term: &Term) -> Result<Vec<MenuSpec>, String> {
get_list(term)?
.iter()
.map(decode_menu)
.collect::<Result<Vec<_>, _>>()
}
fn decode_menu(term: &Term) -> Result<MenuSpec, String> {
let map = expect_map(term)?;
ensure_allowed_fields(map, &["label", "items"], "menu")?;
Ok(MenuSpec {
label: get_string_field(map, "label")?,
items: decode_menu_items(get_required_field(map, "items")?)?,
})
}
fn decode_menu_items(term: &Term) -> Result<Vec<MenuItemSpec>, String> {
get_list(term)?
.iter()
.map(decode_menu_item)
.collect::<Result<Vec<_>, _>>()
}
fn decode_menu_item(term: &Term) -> Result<MenuItemSpec, String> {
if matches!(term, Term::Atom(atom) if atom.name == "separator") {
return Ok(MenuItemSpec::Separator);
}
let map = expect_map(term)?;
if get_optional_boolean_field(map, "separator")? == Some(true) {
ensure_allowed_fields(map, &["separator"], "menu item separator")?;
return Ok(MenuItemSpec::Separator);
}
if get_field(map, "items").is_some() {
ensure_allowed_fields(map, &["label", "items"], "menu item submenu")?;
return Ok(MenuItemSpec::Submenu(MenuSpec {
label: get_string_field(map, "label")?,
items: decode_menu_items(get_required_field(map, "items")?)?,
}));
}
if get_field(map, "system_menu").is_some() {
ensure_allowed_fields(map, &["label", "system_menu"], "menu item system menu")?;
return Ok(MenuItemSpec::SystemMenu(MenuSystemMenuSpec {
label: get_string_field(map, "label")?,
menu_type: get_system_menu_type_field(map, "system_menu")?,
}));
}
ensure_allowed_fields(
map,
&[
"id",
"label",
"callback",
"shortcut",
"enabled",
"os_action",
],
"menu item action",
)?;
let callback = get_optional_string_field(map, "callback")?;
let os_action = get_optional_os_action_field(map, "os_action")?;
match (&callback, os_action) {
(Some(_), Some(_)) => {
return Err("menu item cannot define both callback and os_action".to_owned());
}
(None, None) => return Err("menu item requires callback or os_action".to_owned()),
_ => {}
}
Ok(MenuItemSpec::Action(MenuActionSpec {
id: get_string_field(map, "id")?,
label: get_string_field(map, "label")?,
callback,
shortcut: get_optional_shortcut_field(map, "shortcut")?,
enabled: get_optional_boolean_field(map, "enabled")?.unwrap_or(true),
os_action,
}))
}
fn get_optional_shortcut_field(
map: &HashMap<Term, Term>,
key: &str,
) -> Result<Option<String>, String> {
match get_field(map, key) {
Some(term) => {
let shortcut = term_to_string(term)?;
Keystroke::parse(&shortcut).map_err(|error| error.to_string())?;
Ok(Some(shortcut))
}
None => Ok(None),
}
}
fn get_system_menu_type_field(
map: &HashMap<Term, Term>,
key: &str,
) -> Result<MenuSystemMenuType, String> {
match get_required_field(map, key)? {
Term::Atom(atom) => parse_system_menu_type(&atom.name),
other => Err(format!("expected system_menu atom, got {other}")),
}
}
fn parse_system_menu_type(menu_type: &str) -> Result<MenuSystemMenuType, String> {
match menu_type {
"services" => Ok(MenuSystemMenuType::Services),
other => Err(format!("unsupported system_menu: {other}")),
}
}
fn get_optional_os_action_field(
map: &HashMap<Term, Term>,
key: &str,
) -> Result<Option<MenuOsAction>, String> {
match get_field(map, key) {
Some(Term::Atom(atom)) => parse_os_action(&atom.name).map(Some),
Some(other) => Err(format!("expected os_action atom, got {other}")),
None => Ok(None),
}
}
fn parse_os_action(action: &str) -> Result<MenuOsAction, String> {
match action {
"cut" => Ok(MenuOsAction::Cut),
"copy" => Ok(MenuOsAction::Copy),
"paste" => Ok(MenuOsAction::Paste),
"select_all" => Ok(MenuOsAction::SelectAll),
"undo" => Ok(MenuOsAction::Undo),
"redo" => Ok(MenuOsAction::Redo),
other => Err(format!("unsupported os_action: {other}")),
}
}
fn ensure_allowed_fields(
map: &HashMap<Term, Term>,
allowed: &[&str],
context: &str,
) -> Result<(), String> {
for key in map.keys() {
let Some(key_name) = etf_decode::atom_name(key) else {
return Err(format!("{context} has non-atom key: {key}"));
};
if !allowed.contains(&key_name) {
return Err(format!("unknown {context} key: {key_name}"));
}
}
Ok(())
}
fn get_required_field<'a>(map: &'a HashMap<Term, Term>, key: &str) -> Result<&'a Term, String> {
get_field(map, key).ok_or_else(|| format!("missing required field: {key}"))
}
fn get_string_field(map: &HashMap<Term, Term>, key: &str) -> Result<String, String> {
term_to_string(get_required_field(map, key)?)
}
fn get_optional_string_field(
map: &HashMap<Term, Term>,
key: &str,
) -> Result<Option<String>, String> {
match get_field(map, key) {
Some(term) => term_to_string(term).map(Some),
None => Ok(None),
}
}
fn get_optional_boolean_field(
map: &HashMap<Term, Term>,
key: &str,
) -> Result<Option<bool>, String> {
match get_field(map, key) {
Some(Term::Atom(atom)) if atom.name == "true" => Ok(Some(true)),
Some(Term::Atom(atom)) if atom.name == "false" => Ok(Some(false)),
Some(other) => Err(format!("expected boolean field {key}, got {other}")),
None => Ok(None),
}
}
fn get_field<'a>(map: &'a HashMap<Term, Term>, key: &str) -> Option<&'a Term> {
etf_decode::get_atom_keyed_field(map, key)
}
fn expect_map(term: &Term) -> Result<&HashMap<Term, Term>, String> {
etf_decode::expect_hash_map(term, "map")
}
fn get_list(term: &Term) -> Result<&Vec<Term>, String> {
etf_decode::expect_list(term)
}
fn term_to_string(term: &Term) -> Result<String, String> {
etf_decode::term_to_binary_string(term)
}
#[cfg(test)]
#[path = "menu_tests.rs"]
mod tests;