application_menu.rs

  1use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions};
  2use settings::Settings;
  3
  4use schemars::JsonSchema;
  5use serde::Deserialize;
  6
  7use smallvec::SmallVec;
  8use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
  9
 10use crate::title_bar_settings::TitleBarSettings;
 11
 12actions!(
 13    app_menu,
 14    [
 15        /// Activates the menu on the right in the client-side application menu.
 16        ///
 17        /// Does not apply to platform menu bars (e.g. on macOS).
 18        ActivateMenuRight,
 19        /// Activates the menu on the left in the client-side application menu.
 20        ///
 21        /// Does not apply to platform menu bars (e.g. on macOS).
 22        ActivateMenuLeft
 23    ]
 24);
 25
 26/// Opens the named menu in the client-side application menu.
 27///
 28/// Does not apply to platform menu bars (e.g. on macOS).
 29#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)]
 30#[action(namespace = app_menu)]
 31pub struct OpenApplicationMenu(String);
 32
 33#[cfg(not(target_os = "macos"))]
 34pub enum ActivateDirection {
 35    Left,
 36    Right,
 37}
 38
 39#[derive(Clone)]
 40struct MenuEntry {
 41    menu: OwnedMenu,
 42    handle: PopoverMenuHandle<ContextMenu>,
 43}
 44
 45pub struct ApplicationMenu {
 46    entries: SmallVec<[MenuEntry; 8]>,
 47    pending_menu_open: Option<String>,
 48}
 49
 50impl ApplicationMenu {
 51    pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
 52        let menus = cx.get_menus().unwrap_or_default();
 53        Self {
 54            entries: menus
 55                .into_iter()
 56                .map(|menu| MenuEntry {
 57                    menu,
 58                    handle: PopoverMenuHandle::default(),
 59                })
 60                .collect(),
 61            pending_menu_open: None,
 62        }
 63    }
 64
 65    fn sanitize_menu_items(items: Vec<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
 66        let mut cleaned = Vec::new();
 67        let mut last_was_separator = false;
 68
 69        for item in items {
 70            match item {
 71                OwnedMenuItem::Separator => {
 72                    if !last_was_separator {
 73                        cleaned.push(item);
 74                        last_was_separator = true;
 75                    }
 76                }
 77                OwnedMenuItem::Submenu(submenu) => {
 78                    // Skip empty submenus
 79                    if !submenu.items.is_empty() {
 80                        cleaned.push(OwnedMenuItem::Submenu(submenu));
 81                        last_was_separator = false;
 82                    }
 83                }
 84                item => {
 85                    cleaned.push(item);
 86                    last_was_separator = false;
 87                }
 88            }
 89        }
 90
 91        // Remove trailing separator
 92        if let Some(OwnedMenuItem::Separator) = cleaned.last() {
 93            cleaned.pop();
 94        }
 95
 96        cleaned
 97    }
 98
 99    fn build_menu_from_items(
100        entry: MenuEntry,
101        window: &mut Window,
102        cx: &mut App,
103    ) -> Entity<ContextMenu> {
104        ContextMenu::build(window, cx, |menu, window, cx| {
105            // Grab current focus handle so menu can shown items in context with the focused element
106            let menu = menu.when_some(window.focused(cx), |menu, focused| menu.context(focused));
107            let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
108
109            sanitized_items
110                .into_iter()
111                .fold(menu, |menu, item| match item {
112                    OwnedMenuItem::Separator => menu.separator(),
113                    OwnedMenuItem::Action {
114                        name,
115                        action,
116                        checked,
117                        disabled,
118                        ..
119                    } => menu.action_checked_with_disabled(name, action, checked, disabled),
120                    OwnedMenuItem::Submenu(submenu) => {
121                        submenu
122                            .items
123                            .into_iter()
124                            .fold(menu, |menu, item| match item {
125                                OwnedMenuItem::Separator => menu.separator(),
126                                OwnedMenuItem::Action {
127                                    name,
128                                    action,
129                                    checked,
130                                    disabled,
131                                    ..
132                                } => menu
133                                    .action_checked_with_disabled(name, action, checked, disabled),
134                                OwnedMenuItem::Submenu(_) => menu,
135                                OwnedMenuItem::SystemMenu(_) => {
136                                    // A system menu doesn't make sense in this context, so ignore it
137                                    menu
138                                }
139                            })
140                    }
141                    OwnedMenuItem::SystemMenu(_) => {
142                        // A system menu doesn't make sense in this context, so ignore it
143                        menu
144                    }
145                })
146        })
147    }
148
149    fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
150        let handle = entry.handle.clone();
151
152        let menu_name = entry.menu.name.clone();
153        let entry = entry.clone();
154
155        // Application menu must have same ids as first menu item in standard menu
156        div()
157            .id(format!("{}-menu-item", menu_name))
158            .occlude()
159            .child(
160                PopoverMenu::new(format!("{}-menu-popover", menu_name))
161                    .menu(move |window, cx| {
162                        Self::build_menu_from_items(entry.clone(), window, cx).into()
163                    })
164                    .trigger_with_tooltip(
165                        IconButton::new(
166                            SharedString::from(format!("{}-menu-trigger", menu_name)),
167                            ui::IconName::Menu,
168                        )
169                        .style(ButtonStyle::Subtle)
170                        .icon_size(IconSize::Small),
171                        Tooltip::text("Open Application Menu"),
172                    )
173                    .with_handle(handle),
174            )
175    }
176
177    fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
178        let current_handle = entry.handle.clone();
179
180        let menu_name = entry.menu.name.clone();
181        let entry = entry.clone();
182
183        let all_handles: Vec<_> = self
184            .entries
185            .iter()
186            .map(|entry| entry.handle.clone())
187            .collect();
188
189        div()
190            .id(format!("{}-menu-item", menu_name))
191            .occlude()
192            .child(
193                PopoverMenu::new(format!("{}-menu-popover", menu_name))
194                    .menu(move |window, cx| {
195                        Self::build_menu_from_items(entry.clone(), window, cx).into()
196                    })
197                    .trigger(
198                        Button::new(
199                            SharedString::from(format!("{}-menu-trigger", menu_name)),
200                            menu_name,
201                        )
202                        .style(ButtonStyle::Subtle)
203                        .label_size(LabelSize::Small),
204                    )
205                    .with_handle(current_handle.clone()),
206            )
207            .on_hover(move |hover_enter, window, cx| {
208                if *hover_enter && !current_handle.is_deployed() {
209                    all_handles.iter().for_each(|h| h.hide(cx));
210
211                    // We need to defer this so that this menu handle can take focus from the previous menu
212                    let handle = current_handle.clone();
213                    window.defer(cx, move |window, cx| handle.show(window, cx));
214                }
215            })
216    }
217
218    #[cfg(not(target_os = "macos"))]
219    pub fn open_menu(
220        &mut self,
221        action: &OpenApplicationMenu,
222        _window: &mut Window,
223        _cx: &mut Context<Self>,
224    ) {
225        self.pending_menu_open = Some(action.0.clone());
226    }
227
228    #[cfg(not(target_os = "macos"))]
229    pub fn navigate_menus_in_direction(
230        &mut self,
231        direction: ActivateDirection,
232        window: &mut Window,
233        cx: &mut Context<Self>,
234    ) {
235        let current_index = self
236            .entries
237            .iter()
238            .position(|entry| entry.handle.is_deployed());
239        let Some(current_index) = current_index else {
240            return;
241        };
242
243        let next_index = match direction {
244            ActivateDirection::Left => {
245                if current_index == 0 {
246                    self.entries.len() - 1
247                } else {
248                    current_index - 1
249                }
250            }
251            ActivateDirection::Right => {
252                if current_index == self.entries.len() - 1 {
253                    0
254                } else {
255                    current_index + 1
256                }
257            }
258        };
259
260        self.entries[current_index].handle.hide(cx);
261
262        // We need to defer this so that this menu handle can take focus from the previous menu
263        let next_handle = self.entries[next_index].handle.clone();
264        cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
265    }
266
267    pub fn all_menus_shown(&self, cx: &mut Context<Self>) -> bool {
268        show_menus(cx)
269            || self.entries.iter().any(|entry| entry.handle.is_deployed())
270            || self.pending_menu_open.is_some()
271    }
272}
273
274pub(crate) fn show_menus(cx: &mut App) -> bool {
275    TitleBarSettings::get_global(cx).show_menus
276        && (cfg!(not(target_os = "macos")) || option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some())
277}
278
279impl Render for ApplicationMenu {
280    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
281        let all_menus_shown = self.all_menus_shown(cx);
282
283        if let Some(pending_menu_open) = self.pending_menu_open.take()
284            && let Some(entry) = self
285                .entries
286                .iter()
287                .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
288        {
289            let handle_to_show = entry.handle.clone();
290            let handles_to_hide: Vec<_> = self
291                .entries
292                .iter()
293                .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
294                .map(|e| e.handle.clone())
295                .collect();
296
297            if handles_to_hide.is_empty() {
298                // We need to wait for the next frame to show all menus first,
299                // before we can handle show/hide operations
300                window.on_next_frame(move |window, cx| {
301                    handles_to_hide.iter().for_each(|handle| handle.hide(cx));
302                    window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
303                });
304            } else {
305                // Since menus are already shown, we can directly handle show/hide operations
306                handles_to_hide.iter().for_each(|handle| handle.hide(cx));
307                cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
308            }
309        }
310
311        div()
312            .key_context("ApplicationMenu")
313            .flex()
314            .flex_row()
315            .gap_x_1()
316            .when(!all_menus_shown && !self.entries.is_empty(), |this| {
317                this.child(self.render_application_menu(&self.entries[0]))
318            })
319            .when(all_menus_shown, |this| {
320                this.children(
321                    self.entries
322                        .iter()
323                        .map(|entry| self.render_standard_menu(entry)),
324                )
325            })
326    }
327}