application_menu.rs

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