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::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
 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(
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                        .when(!handle.is_deployed(), |this| {
144                            this.tooltip(Tooltip::text("Open Application Menu"))
145                        }),
146                    )
147                    .with_handle(handle),
148            )
149    }
150
151    fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
152        let current_handle = entry.handle.clone();
153
154        let menu_name = entry.menu.name.clone();
155        let entry = entry.clone();
156
157        let all_handles: Vec<_> = self
158            .entries
159            .iter()
160            .map(|entry| entry.handle.clone())
161            .collect();
162
163        div()
164            .id(SharedString::from(format!("{}-menu-item", menu_name)))
165            .occlude()
166            .child(
167                PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
168                    .menu(move |window, cx| {
169                        Self::build_menu_from_items(entry.clone(), window, cx).into()
170                    })
171                    .trigger(
172                        Button::new(
173                            SharedString::from(format!("{}-menu-trigger", menu_name)),
174                            menu_name.clone(),
175                        )
176                        .style(ButtonStyle::Subtle)
177                        .label_size(LabelSize::Small),
178                    )
179                    .with_handle(current_handle.clone()),
180            )
181            .on_hover(move |hover_enter, window, cx| {
182                if *hover_enter && !current_handle.is_deployed() {
183                    all_handles.iter().for_each(|h| h.hide(cx));
184
185                    // We need to defer this so that this menu handle can take focus from the previous menu
186                    let handle = current_handle.clone();
187                    window.defer(cx, move |window, cx| handle.show(window, cx));
188                }
189            })
190    }
191
192    #[cfg(not(target_os = "macos"))]
193    pub fn open_menu(
194        &mut self,
195        action: &OpenApplicationMenu,
196        _window: &mut Window,
197        _cx: &mut Context<Self>,
198    ) {
199        self.pending_menu_open = Some(action.0.clone());
200    }
201
202    #[cfg(not(target_os = "macos"))]
203    pub fn navigate_menus_in_direction(
204        &mut self,
205        direction: ActivateDirection,
206        window: &mut Window,
207        cx: &mut Context<Self>,
208    ) {
209        let current_index = self
210            .entries
211            .iter()
212            .position(|entry| entry.handle.is_deployed());
213        let Some(current_index) = current_index else {
214            return;
215        };
216
217        let next_index = match direction {
218            ActivateDirection::Left => {
219                if current_index == 0 {
220                    self.entries.len() - 1
221                } else {
222                    current_index - 1
223                }
224            }
225            ActivateDirection::Right => {
226                if current_index == self.entries.len() - 1 {
227                    0
228                } else {
229                    current_index + 1
230                }
231            }
232        };
233
234        self.entries[current_index].handle.hide(cx);
235
236        // We need to defer this so that this menu handle can take focus from the previous menu
237        let next_handle = self.entries[next_index].handle.clone();
238        cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
239    }
240
241    pub fn all_menus_shown(&self) -> bool {
242        self.entries.iter().any(|entry| entry.handle.is_deployed())
243            || self.pending_menu_open.is_some()
244    }
245}
246
247impl Render for ApplicationMenu {
248    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
249        let all_menus_shown = self.all_menus_shown();
250
251        if let Some(pending_menu_open) = self.pending_menu_open.take() {
252            if let Some(entry) = self
253                .entries
254                .iter()
255                .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
256            {
257                let handle_to_show = entry.handle.clone();
258                let handles_to_hide: Vec<_> = self
259                    .entries
260                    .iter()
261                    .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
262                    .map(|e| e.handle.clone())
263                    .collect();
264
265                if handles_to_hide.is_empty() {
266                    // We need to wait for the next frame to show all menus first,
267                    // before we can handle show/hide operations
268                    window.on_next_frame(move |window, cx| {
269                        handles_to_hide.iter().for_each(|handle| handle.hide(cx));
270                        window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
271                    });
272                } else {
273                    // Since menus are already shown, we can directly handle show/hide operations
274                    handles_to_hide.iter().for_each(|handle| handle.hide(cx));
275                    cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
276                }
277            }
278        }
279
280        div()
281            .key_context("ApplicationMenu")
282            .flex()
283            .flex_row()
284            .gap_x_1()
285            .when(!all_menus_shown && !self.entries.is_empty(), |this| {
286                this.child(self.render_application_menu(&self.entries[0]))
287            })
288            .when(all_menus_shown, |this| {
289                this.children(
290                    self.entries
291                        .iter()
292                        .map(|entry| self.render_standard_menu(entry)),
293                )
294            })
295    }
296}