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