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