application_menu.rs

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