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::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
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_with_tooltip(
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 Tooltip::text("Open Application Menu"),
144 )
145 .with_handle(handle),
146 )
147 }
148
149 fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
150 let current_handle = entry.handle.clone();
151
152 let menu_name = entry.menu.name.clone();
153 let entry = entry.clone();
154
155 let all_handles: Vec<_> = self
156 .entries
157 .iter()
158 .map(|entry| entry.handle.clone())
159 .collect();
160
161 div()
162 .id(SharedString::from(format!("{}-menu-item", menu_name)))
163 .occlude()
164 .child(
165 PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
166 .menu(move |window, cx| {
167 Self::build_menu_from_items(entry.clone(), window, cx).into()
168 })
169 .trigger(
170 Button::new(
171 SharedString::from(format!("{}-menu-trigger", menu_name)),
172 menu_name.clone(),
173 )
174 .style(ButtonStyle::Subtle)
175 .label_size(LabelSize::Small),
176 )
177 .with_handle(current_handle.clone()),
178 )
179 .on_hover(move |hover_enter, window, cx| {
180 if *hover_enter && !current_handle.is_deployed() {
181 all_handles.iter().for_each(|h| h.hide(cx));
182
183 // We need to defer this so that this menu handle can take focus from the previous menu
184 let handle = current_handle.clone();
185 window.defer(cx, move |window, cx| handle.show(window, cx));
186 }
187 })
188 }
189
190 #[cfg(not(target_os = "macos"))]
191 pub fn open_menu(
192 &mut self,
193 action: &OpenApplicationMenu,
194 _window: &mut Window,
195 _cx: &mut Context<Self>,
196 ) {
197 self.pending_menu_open = Some(action.0.clone());
198 }
199
200 #[cfg(not(target_os = "macos"))]
201 pub fn navigate_menus_in_direction(
202 &mut self,
203 direction: ActivateDirection,
204 window: &mut Window,
205 cx: &mut Context<Self>,
206 ) {
207 let current_index = self
208 .entries
209 .iter()
210 .position(|entry| entry.handle.is_deployed());
211 let Some(current_index) = current_index else {
212 return;
213 };
214
215 let next_index = match direction {
216 ActivateDirection::Left => {
217 if current_index == 0 {
218 self.entries.len() - 1
219 } else {
220 current_index - 1
221 }
222 }
223 ActivateDirection::Right => {
224 if current_index == self.entries.len() - 1 {
225 0
226 } else {
227 current_index + 1
228 }
229 }
230 };
231
232 self.entries[current_index].handle.hide(cx);
233
234 // We need to defer this so that this menu handle can take focus from the previous menu
235 let next_handle = self.entries[next_index].handle.clone();
236 cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
237 }
238
239 pub fn all_menus_shown(&self) -> bool {
240 self.entries.iter().any(|entry| entry.handle.is_deployed())
241 || self.pending_menu_open.is_some()
242 }
243}
244
245impl Render for ApplicationMenu {
246 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
247 let all_menus_shown = self.all_menus_shown();
248
249 if let Some(pending_menu_open) = self.pending_menu_open.take() {
250 if let Some(entry) = self
251 .entries
252 .iter()
253 .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
254 {
255 let handle_to_show = entry.handle.clone();
256 let handles_to_hide: Vec<_> = self
257 .entries
258 .iter()
259 .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
260 .map(|e| e.handle.clone())
261 .collect();
262
263 if handles_to_hide.is_empty() {
264 // We need to wait for the next frame to show all menus first,
265 // before we can handle show/hide operations
266 window.on_next_frame(move |window, cx| {
267 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
268 window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
269 });
270 } else {
271 // Since menus are already shown, we can directly handle show/hide operations
272 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
273 cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
274 }
275 }
276 }
277
278 div()
279 .key_context("ApplicationMenu")
280 .flex()
281 .flex_row()
282 .gap_x_1()
283 .when(!all_menus_shown && !self.entries.is_empty(), |this| {
284 this.child(self.render_application_menu(&self.entries[0]))
285 })
286 .when(all_menus_shown, |this| {
287 this.children(
288 self.entries
289 .iter()
290 .map(|entry| self.render_standard_menu(entry)),
291 )
292 })
293 }
294}