application_menu.rs

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