application_menu.rs

  1use gpui::{OwnedMenu, OwnedMenuItem, View};
  2use smallvec::SmallVec;
  3use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
  4
  5#[derive(Clone)]
  6struct MenuEntry {
  7    menu: OwnedMenu,
  8    handle: PopoverMenuHandle<ContextMenu>,
  9}
 10
 11pub struct ApplicationMenu {
 12    entries: SmallVec<[MenuEntry; 8]>,
 13}
 14
 15impl ApplicationMenu {
 16    pub fn new(cx: &mut ViewContext<Self>) -> Self {
 17        let menus = cx.get_menus().unwrap_or_default();
 18        Self {
 19            entries: menus
 20                .into_iter()
 21                .map(|menu| MenuEntry {
 22                    menu,
 23                    handle: PopoverMenuHandle::default(),
 24                })
 25                .collect(),
 26        }
 27    }
 28
 29    fn sanitize_menu_items(items: Vec<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
 30        let mut cleaned = Vec::new();
 31        let mut last_was_separator = false;
 32
 33        for item in items {
 34            match item {
 35                OwnedMenuItem::Separator => {
 36                    if !last_was_separator {
 37                        cleaned.push(item);
 38                        last_was_separator = true;
 39                    }
 40                }
 41                OwnedMenuItem::Submenu(submenu) => {
 42                    // Skip empty submenus
 43                    if !submenu.items.is_empty() {
 44                        cleaned.push(OwnedMenuItem::Submenu(submenu));
 45                        last_was_separator = false;
 46                    }
 47                }
 48                item => {
 49                    cleaned.push(item);
 50                    last_was_separator = false;
 51                }
 52            }
 53        }
 54
 55        // Remove trailing separator
 56        if let Some(OwnedMenuItem::Separator) = cleaned.last() {
 57            cleaned.pop();
 58        }
 59
 60        cleaned
 61    }
 62
 63    fn build_menu_from_items(entry: MenuEntry, cx: &mut WindowContext) -> View<ContextMenu> {
 64        ContextMenu::build(cx, |menu, cx| {
 65            let menu = menu.when_some(cx.focused(), |menu, focused| menu.context(focused));
 66            let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
 67
 68            sanitized_items
 69                .into_iter()
 70                .fold(menu, |menu, item| match item {
 71                    OwnedMenuItem::Separator => menu.separator(),
 72                    OwnedMenuItem::Action { name, action, .. } => menu.action(name, action),
 73                    OwnedMenuItem::Submenu(submenu) => {
 74                        submenu
 75                            .items
 76                            .into_iter()
 77                            .fold(menu, |menu, item| match item {
 78                                OwnedMenuItem::Separator => menu.separator(),
 79                                OwnedMenuItem::Action { name, action, .. } => {
 80                                    menu.action(name, action)
 81                                }
 82                                OwnedMenuItem::Submenu(_) => menu,
 83                            })
 84                    }
 85                })
 86        })
 87    }
 88
 89    fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
 90        let handle = entry.handle.clone();
 91
 92        let menu_name = entry.menu.name.clone();
 93        let entry = entry.clone();
 94
 95        // Application menu must have same ids as first menu item in standard menu
 96        // Hence, we generate ids based on the menu name
 97        div()
 98            .id(SharedString::from(format!("{}-menu-item", menu_name)))
 99            .occlude()
100            .child(
101                PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
102                    .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
103                    .trigger(
104                        IconButton::new(
105                            SharedString::from(format!("{}-menu-trigger", menu_name)),
106                            ui::IconName::Menu,
107                        )
108                        .style(ButtonStyle::Subtle)
109                        .icon_size(IconSize::Small)
110                        .when(!handle.is_deployed(), |this| {
111                            this.tooltip(|cx| Tooltip::text("Open Application Menu", cx))
112                        }),
113                    )
114                    .with_handle(handle),
115            )
116    }
117
118    fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
119        let current_handle = entry.handle.clone();
120
121        let menu_name = entry.menu.name.clone();
122        let entry = entry.clone();
123
124        let all_handles: Vec<_> = self
125            .entries
126            .iter()
127            .map(|entry| entry.handle.clone())
128            .collect();
129
130        div()
131            .id(SharedString::from(format!("{}-menu-item", menu_name)))
132            .occlude()
133            .child(
134                PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
135                    .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
136                    .trigger(
137                        Button::new(
138                            SharedString::from(format!("{}-menu-trigger", menu_name)),
139                            menu_name.clone(),
140                        )
141                        .style(ButtonStyle::Subtle)
142                        .label_size(LabelSize::Small),
143                    )
144                    .with_handle(current_handle.clone()),
145            )
146            .on_hover(move |hover_enter, cx| {
147                // Skip if menu is already open to avoid focus issue
148                if *hover_enter && !current_handle.is_deployed() {
149                    all_handles.iter().for_each(|h| h.hide(cx));
150
151                    // Defer to prevent focus race condition with the previously open menu
152                    let handle = current_handle.clone();
153                    cx.defer(move |cx| handle.show(cx));
154                }
155            })
156    }
157
158    pub fn is_any_deployed(&self) -> bool {
159        self.entries.iter().any(|entry| entry.handle.is_deployed())
160    }
161}
162
163impl Render for ApplicationMenu {
164    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
165        let is_any_deployed = self.is_any_deployed();
166        div()
167            .flex()
168            .flex_row()
169            .gap_x_1()
170            .when(!is_any_deployed && !self.entries.is_empty(), |this| {
171                this.child(self.render_application_menu(&self.entries[0]))
172            })
173            .when(is_any_deployed, |this| {
174                this.children(
175                    self.entries
176                        .iter()
177                        .map(|entry| self.render_standard_menu(entry)),
178                )
179            })
180    }
181}