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