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}