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