1use gpui::{OwnedMenu, OwnedMenuItem, View};
2use smallvec::SmallVec;
3use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
4
5#[derive(Clone)]
6struct MenuEntry {
7 menu: OwnedMenu,
8 handle: PopoverMenuHandle<ContextMenu>,
9}
10
11pub struct ApplicationMenu {
12 entries: SmallVec<[MenuEntry; 8]>,
13}
14
15impl ApplicationMenu {
16 pub fn new(cx: &mut ViewContext<Self>) -> Self {
17 let menus = cx.get_menus().unwrap_or_default();
18 Self {
19 entries: menus
20 .into_iter()
21 .map(|menu| MenuEntry {
22 menu,
23 handle: PopoverMenuHandle::default(),
24 })
25 .collect(),
26 }
27 }
28
29 fn sanitize_menu_items(items: Vec<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
30 let mut cleaned = Vec::new();
31 let mut last_was_separator = false;
32
33 for item in items {
34 match item {
35 OwnedMenuItem::Separator => {
36 if !last_was_separator {
37 cleaned.push(item);
38 last_was_separator = true;
39 }
40 }
41 OwnedMenuItem::Submenu(submenu) => {
42 // Skip empty submenus
43 if !submenu.items.is_empty() {
44 cleaned.push(OwnedMenuItem::Submenu(submenu));
45 last_was_separator = false;
46 }
47 }
48 item => {
49 cleaned.push(item);
50 last_was_separator = false;
51 }
52 }
53 }
54
55 // Remove trailing separator
56 if let Some(OwnedMenuItem::Separator) = cleaned.last() {
57 cleaned.pop();
58 }
59
60 cleaned
61 }
62
63 fn build_menu_from_items(entry: MenuEntry, cx: &mut WindowContext) -> View<ContextMenu> {
64 ContextMenu::build(cx, |menu, cx| {
65 let menu = menu.when_some(cx.focused(), |menu, focused| menu.context(focused));
66 let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
67
68 sanitized_items
69 .into_iter()
70 .fold(menu, |menu, item| match item {
71 OwnedMenuItem::Separator => menu.separator(),
72 OwnedMenuItem::Action { name, action, .. } => menu.action(name, action),
73 OwnedMenuItem::Submenu(submenu) => {
74 submenu
75 .items
76 .into_iter()
77 .fold(menu, |menu, item| match item {
78 OwnedMenuItem::Separator => menu.separator(),
79 OwnedMenuItem::Action { name, action, .. } => {
80 menu.action(name, action)
81 }
82 OwnedMenuItem::Submenu(_) => menu,
83 })
84 }
85 })
86 })
87 }
88
89 fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
90 let handle = entry.handle.clone();
91
92 let menu_name = entry.menu.name.clone();
93 let entry = entry.clone();
94
95 // Application menu must have same ids as first menu item in standard menu
96 // Hence, we generate ids based on the menu name
97 div()
98 .id(SharedString::from(format!("{}-menu-item", menu_name)))
99 .occlude()
100 .child(
101 PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
102 .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
103 .trigger(
104 IconButton::new(
105 SharedString::from(format!("{}-menu-trigger", menu_name)),
106 ui::IconName::Menu,
107 )
108 .style(ButtonStyle::Subtle)
109 .icon_size(IconSize::Small)
110 .when(!handle.is_deployed(), |this| {
111 this.tooltip(|cx| Tooltip::text("Open Application Menu", cx))
112 }),
113 )
114 .with_handle(handle),
115 )
116 }
117
118 fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
119 let current_handle = entry.handle.clone();
120
121 let menu_name = entry.menu.name.clone();
122 let entry = entry.clone();
123
124 let all_handles: Vec<_> = self
125 .entries
126 .iter()
127 .map(|entry| entry.handle.clone())
128 .collect();
129
130 div()
131 .id(SharedString::from(format!("{}-menu-item", menu_name)))
132 .occlude()
133 .child(
134 PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name)))
135 .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into())
136 .trigger(
137 Button::new(
138 SharedString::from(format!("{}-menu-trigger", menu_name)),
139 menu_name.clone(),
140 )
141 .style(ButtonStyle::Subtle)
142 .label_size(LabelSize::Small),
143 )
144 .with_handle(current_handle.clone()),
145 )
146 .on_hover(move |hover_enter, cx| {
147 // Skip if menu is already open to avoid focus issue
148 if *hover_enter && !current_handle.is_deployed() {
149 all_handles.iter().for_each(|h| h.hide(cx));
150
151 // Defer to prevent focus race condition with the previously open menu
152 let handle = current_handle.clone();
153 cx.defer(move |cx| handle.show(cx));
154 }
155 })
156 }
157
158 pub fn is_any_deployed(&self) -> bool {
159 self.entries.iter().any(|entry| entry.handle.is_deployed())
160 }
161}
162
163impl Render for ApplicationMenu {
164 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
165 let is_any_deployed = self.is_any_deployed();
166 div()
167 .flex()
168 .flex_row()
169 .gap_x_1()
170 .when(!is_any_deployed && !self.entries.is_empty(), |this| {
171 this.child(self.render_application_menu(&self.entries[0]))
172 })
173 .when(is_any_deployed, |this| {
174 this.children(
175 self.entries
176 .iter()
177 .map(|entry| self.render_standard_menu(entry)),
178 )
179 })
180 }
181}