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