1use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions};
2use settings::Settings;
3
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use smallvec::SmallVec;
8use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
9
10use crate::title_bar_settings::TitleBarSettings;
11
12actions!(
13 app_menu,
14 [
15 /// Activates the menu on the right in the client-side application menu.
16 ///
17 /// Does not apply to platform menu bars (e.g. on macOS).
18 ActivateMenuRight,
19 /// Activates the menu on the left in the client-side application menu.
20 ///
21 /// Does not apply to platform menu bars (e.g. on macOS).
22 ActivateMenuLeft
23 ]
24);
25
26/// Opens the named menu in the client-side application menu.
27///
28/// Does not apply to platform menu bars (e.g. on 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 {
114 name,
115 action,
116 checked,
117 disabled,
118 ..
119 } => menu.action_checked_with_disabled(name, action, checked, disabled),
120 OwnedMenuItem::Submenu(submenu) => {
121 submenu
122 .items
123 .into_iter()
124 .fold(menu, |menu, item| match item {
125 OwnedMenuItem::Separator => menu.separator(),
126 OwnedMenuItem::Action {
127 name,
128 action,
129 checked,
130 disabled,
131 ..
132 } => menu
133 .action_checked_with_disabled(name, action, checked, disabled),
134 OwnedMenuItem::Submenu(_) => menu,
135 OwnedMenuItem::SystemMenu(_) => {
136 // A system menu doesn't make sense in this context, so ignore it
137 menu
138 }
139 })
140 }
141 OwnedMenuItem::SystemMenu(_) => {
142 // A system menu doesn't make sense in this context, so ignore it
143 menu
144 }
145 })
146 })
147 }
148
149 fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
150 let handle = entry.handle.clone();
151
152 let menu_name = entry.menu.name.clone();
153 let entry = entry.clone();
154
155 // Application menu must have same ids as first menu item in standard menu
156 div()
157 .id(format!("{}-menu-item", menu_name))
158 .occlude()
159 .child(
160 PopoverMenu::new(format!("{}-menu-popover", menu_name))
161 .menu(move |window, cx| {
162 Self::build_menu_from_items(entry.clone(), window, cx).into()
163 })
164 .trigger_with_tooltip(
165 IconButton::new(
166 SharedString::from(format!("{}-menu-trigger", menu_name)),
167 ui::IconName::Menu,
168 )
169 .style(ButtonStyle::Subtle)
170 .icon_size(IconSize::Small),
171 Tooltip::text("Open Application Menu"),
172 )
173 .with_handle(handle),
174 )
175 }
176
177 fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
178 let current_handle = entry.handle.clone();
179
180 let menu_name = entry.menu.name.clone();
181 let entry = entry.clone();
182
183 let all_handles: Vec<_> = self
184 .entries
185 .iter()
186 .map(|entry| entry.handle.clone())
187 .collect();
188
189 div()
190 .id(format!("{}-menu-item", menu_name))
191 .occlude()
192 .child(
193 PopoverMenu::new(format!("{}-menu-popover", menu_name))
194 .menu(move |window, cx| {
195 Self::build_menu_from_items(entry.clone(), window, cx).into()
196 })
197 .trigger(
198 Button::new(
199 SharedString::from(format!("{}-menu-trigger", menu_name)),
200 menu_name,
201 )
202 .style(ButtonStyle::Subtle)
203 .label_size(LabelSize::Small),
204 )
205 .with_handle(current_handle.clone()),
206 )
207 .on_hover(move |hover_enter, window, cx| {
208 if *hover_enter && !current_handle.is_deployed() {
209 all_handles.iter().for_each(|h| h.hide(cx));
210
211 // We need to defer this so that this menu handle can take focus from the previous menu
212 let handle = current_handle.clone();
213 window.defer(cx, move |window, cx| handle.show(window, cx));
214 }
215 })
216 }
217
218 #[cfg(not(target_os = "macos"))]
219 pub fn open_menu(
220 &mut self,
221 action: &OpenApplicationMenu,
222 _window: &mut Window,
223 _cx: &mut Context<Self>,
224 ) {
225 self.pending_menu_open = Some(action.0.clone());
226 }
227
228 #[cfg(not(target_os = "macos"))]
229 pub fn navigate_menus_in_direction(
230 &mut self,
231 direction: ActivateDirection,
232 window: &mut Window,
233 cx: &mut Context<Self>,
234 ) {
235 let current_index = self
236 .entries
237 .iter()
238 .position(|entry| entry.handle.is_deployed());
239 let Some(current_index) = current_index else {
240 return;
241 };
242
243 let next_index = match direction {
244 ActivateDirection::Left => {
245 if current_index == 0 {
246 self.entries.len() - 1
247 } else {
248 current_index - 1
249 }
250 }
251 ActivateDirection::Right => {
252 if current_index == self.entries.len() - 1 {
253 0
254 } else {
255 current_index + 1
256 }
257 }
258 };
259
260 self.entries[current_index].handle.hide(cx);
261
262 // We need to defer this so that this menu handle can take focus from the previous menu
263 let next_handle = self.entries[next_index].handle.clone();
264 cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
265 }
266
267 pub fn all_menus_shown(&self, cx: &mut Context<Self>) -> bool {
268 show_menus(cx)
269 || self.entries.iter().any(|entry| entry.handle.is_deployed())
270 || self.pending_menu_open.is_some()
271 }
272}
273
274pub(crate) fn show_menus(cx: &mut App) -> bool {
275 TitleBarSettings::get_global(cx).show_menus
276 && (cfg!(not(target_os = "macos")) || option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some())
277}
278
279impl Render for ApplicationMenu {
280 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
281 let all_menus_shown = self.all_menus_shown(cx);
282
283 if let Some(pending_menu_open) = self.pending_menu_open.take()
284 && let Some(entry) = self
285 .entries
286 .iter()
287 .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
288 {
289 let handle_to_show = entry.handle.clone();
290 let handles_to_hide: Vec<_> = self
291 .entries
292 .iter()
293 .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
294 .map(|e| e.handle.clone())
295 .collect();
296
297 if handles_to_hide.is_empty() {
298 // We need to wait for the next frame to show all menus first,
299 // before we can handle show/hide operations
300 window.on_next_frame(move |window, cx| {
301 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
302 window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
303 });
304 } else {
305 // Since menus are already shown, we can directly handle show/hide operations
306 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
307 cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
308 }
309 }
310
311 div()
312 .key_context("ApplicationMenu")
313 .flex()
314 .flex_row()
315 .gap_x_1()
316 .when(!all_menus_shown && !self.entries.is_empty(), |this| {
317 this.child(self.render_application_menu(&self.entries[0]))
318 })
319 .when(all_menus_shown, |this| {
320 this.children(
321 self.entries
322 .iter()
323 .map(|entry| self.render_standard_menu(entry)),
324 )
325 })
326 }
327}