application_menu.rs

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