1use gpui::{impl_actions, OwnedMenu, OwnedMenuItem, View};
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(cx: &mut ViewContext<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(entry: MenuEntry, cx: &mut WindowContext) -> View<ContextMenu> {
79 ContextMenu::build(cx, |menu, cx| {
80 // Grab current focus handle so menu can shown items in context with the focused element
81 let menu = menu.when_some(cx.focused(), |menu, focused| menu.context(focused));
82 let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
83
84 sanitized_items
85 .into_iter()
86 .fold(menu, |menu, item| match item {
87 OwnedMenuItem::Separator => menu.separator(),
88 OwnedMenuItem::Action { name, action, .. } => menu.action(name, action),
89 OwnedMenuItem::Submenu(submenu) => {
90 submenu
91 .items
92 .into_iter()
93 .fold(menu, |menu, item| match item {
94 OwnedMenuItem::Separator => menu.separator(),
95 OwnedMenuItem::Action { name, action, .. } => {
96 menu.action(name, action)
97 }
98 OwnedMenuItem::Submenu(_) => menu,
99 })
100 }
101 })
102 })
103 }
104
105 fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
106 let handle = entry.handle.clone();
107
108 let menu_name = entry.menu.name.clone();
109 let entry = entry.clone();
110
111 // Application menu must have same ids as first menu item in standard menu
112 div()
113 .id(SharedString::from(format!("{}-menu-item", menu_name)))
114 .occlude()
115 .child(
116 PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
117 .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
118 .trigger(
119 IconButton::new(
120 SharedString::from(format!("{}-menu-trigger", menu_name)),
121 ui::IconName::Menu,
122 )
123 .style(ButtonStyle::Subtle)
124 .icon_size(IconSize::Small)
125 .when(!handle.is_deployed(), |this| {
126 this.tooltip(|cx| Tooltip::text("Open Application Menu", cx))
127 }),
128 )
129 .with_handle(handle),
130 )
131 }
132
133 fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
134 let current_handle = entry.handle.clone();
135
136 let menu_name = entry.menu.name.clone();
137 let entry = entry.clone();
138
139 let all_handles: Vec<_> = self
140 .entries
141 .iter()
142 .map(|entry| entry.handle.clone())
143 .collect();
144
145 div()
146 .id(SharedString::from(format!("{}-menu-item", menu_name)))
147 .occlude()
148 .child(
149 PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
150 .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
151 .trigger(
152 Button::new(
153 SharedString::from(format!("{}-menu-trigger", menu_name)),
154 menu_name.clone(),
155 )
156 .style(ButtonStyle::Subtle)
157 .label_size(LabelSize::Small),
158 )
159 .with_handle(current_handle.clone()),
160 )
161 .on_hover(move |hover_enter, cx| {
162 if *hover_enter && !current_handle.is_deployed() {
163 all_handles.iter().for_each(|h| h.hide(cx));
164
165 // We need to defer this so that this menu handle can take focus from the previous menu
166 let handle = current_handle.clone();
167 cx.defer(move |cx| handle.show(cx));
168 }
169 })
170 }
171
172 #[cfg(not(target_os = "macos"))]
173 pub fn open_menu(&mut self, action: &OpenApplicationMenu, _cx: &mut ViewContext<Self>) {
174 self.pending_menu_open = Some(action.0.clone());
175 }
176
177 #[cfg(not(target_os = "macos"))]
178 pub fn navigate_menus_in_direction(
179 &mut self,
180 action: &NavigateApplicationMenuInDirection,
181 cx: &mut ViewContext<Self>,
182 ) {
183 let current_index = self
184 .entries
185 .iter()
186 .position(|entry| entry.handle.is_deployed());
187 let Some(current_index) = current_index else {
188 return;
189 };
190
191 let next_index = match action.0.as_str() {
192 "Left" => {
193 if current_index == 0 {
194 self.entries.len() - 1
195 } else {
196 current_index - 1
197 }
198 }
199 "Right" => {
200 if current_index == self.entries.len() - 1 {
201 0
202 } else {
203 current_index + 1
204 }
205 }
206 _ => return,
207 };
208
209 self.entries[current_index].handle.hide(cx);
210
211 // We need to defer this so that this menu handle can take focus from the previous menu
212 let next_handle = self.entries[next_index].handle.clone();
213 cx.defer(move |_, cx| next_handle.show(cx));
214 }
215
216 pub fn all_menus_shown(&self) -> bool {
217 self.entries.iter().any(|entry| entry.handle.is_deployed())
218 || self.pending_menu_open.is_some()
219 }
220}
221
222impl Render for ApplicationMenu {
223 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
224 let all_menus_shown = self.all_menus_shown();
225
226 if let Some(pending_menu_open) = self.pending_menu_open.take() {
227 if let Some(entry) = self
228 .entries
229 .iter()
230 .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
231 {
232 let handle_to_show = entry.handle.clone();
233 let handles_to_hide: Vec<_> = self
234 .entries
235 .iter()
236 .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
237 .map(|e| e.handle.clone())
238 .collect();
239
240 if handles_to_hide.is_empty() {
241 // We need to wait for the next frame to show all menus first,
242 // before we can handle show/hide operations
243 cx.window_context().on_next_frame(move |cx| {
244 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
245 cx.defer(move |cx| handle_to_show.show(cx));
246 });
247 } else {
248 // Since menus are already shown, we can directly handle show/hide operations
249 handles_to_hide.iter().for_each(|handle| handle.hide(cx));
250 cx.defer(move |_, cx| handle_to_show.show(cx));
251 }
252 }
253 }
254
255 div()
256 .key_context("ApplicationMenu")
257 .flex()
258 .flex_row()
259 .gap_x_1()
260 .when(!all_menus_shown && !self.entries.is_empty(), |this| {
261 this.child(self.render_application_menu(&self.entries[0]))
262 })
263 .when(all_menus_shown, |this| {
264 this.children(
265 self.entries
266 .iter()
267 .map(|entry| self.render_standard_menu(entry)),
268 )
269 })
270 }
271}