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