1use crate::{
2 InteractiveElementExt as _, Selectable, Sizable,
3 actions::{Cancel, SelectLeft, SelectRight},
4 button::{Button, ButtonVariants},
5 global_state::GlobalState,
6 h_flex,
7 menu::PopupMenu,
8 scroll::ScrollableMask,
9};
10use gpui::{
11 App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, FocusHandle, Focusable,
12 InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
13 Render, Role, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription,
14 Window, anchored, deferred, div, prelude::FluentBuilder, px,
15};
16
17const CONTEXT: &str = "AppMenuBar";
18pub fn init(cx: &mut App) {
19 cx.bind_keys([
20 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
21 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
22 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
23 ]);
24}
25
26pub struct AppMenuBar {
28 menus: Vec<Entity<AppMenu>>,
29 selected_index: Option<usize>,
30 action_context: Option<FocusHandle>,
31 scroll_handle: ScrollHandle,
32}
33
34impl AppMenuBar {
35 pub fn new(cx: &mut App) -> Entity<Self> {
37 cx.new(|cx| {
38 let mut this = Self {
39 selected_index: None,
40 action_context: None,
41 menus: Vec::new(),
42 scroll_handle: ScrollHandle::new(),
43 };
44 this.reload(cx);
45 this
46 })
47 }
48
49 pub fn reload(&mut self, cx: &mut Context<Self>) {
51 let menu_bar = cx.entity();
52 let menus: Vec<OwnedMenu> = GlobalState::global(cx)
53 .app_menus()
54 .iter()
55 .cloned()
56 .collect();
57 self.menus = menus
58 .iter()
59 .enumerate()
60 .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx))
61 .collect();
62 self.selected_index = None;
63 self.action_context = None;
64 cx.notify();
65 }
66
67 fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
68 let Some(selected_index) = self.selected_index else {
69 return;
70 };
71
72 let new_ix = if selected_index == 0 {
73 self.menus.len().saturating_sub(1)
74 } else {
75 selected_index.saturating_sub(1)
76 };
77 self.set_selected_index(Some(new_ix), window, cx);
78 }
79
80 fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
81 let Some(selected_index) = self.selected_index else {
82 return;
83 };
84
85 let new_ix = if selected_index + 1 >= self.menus.len() {
86 0
87 } else {
88 selected_index + 1
89 };
90 self.set_selected_index(Some(new_ix), window, cx);
91 }
92
93 fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
94 self.set_selected_index(None, window, cx);
95 }
96
97 fn set_selected_index(
98 &mut self,
99 ix: Option<usize>,
100 window: &mut Window,
101 cx: &mut Context<Self>,
102 ) {
103 if self.selected_index.is_none() && ix.is_some() {
104 self.action_context = window.focused(cx);
105 } else if ix.is_none() {
106 if let Some(action_context) = self.action_context.as_ref() {
107 action_context.focus(window, cx);
108 }
109 self.action_context = None;
110 }
111
112 self.selected_index = ix;
113 cx.notify();
114 }
115
116 #[inline]
117 fn has_activated_menu(&self) -> bool {
118 self.selected_index.is_some()
119 }
120}
121
122impl Render for AppMenuBar {
123 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
124 div()
125 .relative()
126 .size_full()
127 .child(
128 h_flex()
129 .id("app-menu-bar")
130 .role(Role::MenuBar)
131 .key_context(CONTEXT)
132 .on_action(cx.listener(Self::on_move_left))
133 .on_action(cx.listener(Self::on_move_right))
134 .on_action(cx.listener(Self::on_cancel))
135 .size_full()
136 .gap_x_1()
137 .overflow_x_scroll()
138 .lock_scroll_axis()
139 .track_scroll(&self.scroll_handle)
140 .children(self.menus.clone()),
141 )
142 .child(ScrollableMask::vertical_wheel(&self.scroll_handle))
143 }
144}
145
146pub(super) struct AppMenu {
148 menu_bar: Entity<AppMenuBar>,
149 ix: usize,
150 name: SharedString,
151 menu: OwnedMenu,
152 popup_menu: Option<Entity<PopupMenu>>,
153
154 _subscription: Option<Subscription>,
155}
156
157impl AppMenu {
158 pub(super) fn new(
159 ix: usize,
160 menu: &OwnedMenu,
161 menu_bar: Entity<AppMenuBar>,
162 cx: &mut App,
163 ) -> Entity<Self> {
164 let name = menu.name.clone();
165 cx.new(|_| Self {
166 ix,
167 menu_bar,
168 name,
169 menu: menu.clone(),
170 popup_menu: None,
171 _subscription: None,
172 })
173 }
174
175 fn is_open(&self, cx: &App) -> bool {
177 self.menu_bar.read(cx).selected_index == Some(self.ix)
178 }
179
180 fn build_popup_menu(
181 &mut self,
182 window: &mut Window,
183 cx: &mut Context<Self>,
184 ) -> Entity<PopupMenu> {
185 let action_context = self.menu_bar.read(cx).action_context.clone();
186 let popup_menu = match self.popup_menu.as_ref() {
187 None => {
188 let items = self.menu.items.clone();
189 let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
190 menu.with_menu_items(items, window, cx)
191 });
192 popup_menu.update(cx, |menu, cx| {
193 menu.set_action_context(action_context.clone(), cx);
194 });
195 self._subscription =
196 Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
197 self.popup_menu = Some(popup_menu.clone());
198
199 popup_menu
200 }
201 Some(menu) => {
202 menu.update(cx, |menu, cx| {
203 menu.set_action_context(action_context.clone(), cx);
204 });
205 menu.clone()
206 }
207 };
208
209 let focus_handle = popup_menu.read(cx).focus_handle(cx);
210 if !focus_handle.contains_focused(window, cx) {
211 focus_handle.focus(window, cx);
212 }
213
214 popup_menu
215 }
216
217 fn handle_dismiss(
218 &mut self,
219 _: &Entity<PopupMenu>,
220 _: &DismissEvent,
221 window: &mut Window,
222 cx: &mut Context<Self>,
223 ) {
224 self._subscription.take();
225 self.popup_menu.take();
226 self.menu_bar.update(cx, |state, cx| {
227 state.on_cancel(&Cancel, window, cx);
228 });
229 }
230
231 fn handle_trigger_click(
232 &mut self,
233 event: &ClickEvent,
234 window: &mut Window,
235 cx: &mut Context<Self>,
236 ) {
237 if matches!(event, ClickEvent::Mouse(_)) {
238 return;
239 }
240
241 self.toggle(window, cx);
242 }
243
244 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
245 let is_open = self.is_open(cx);
246 _ = self.menu_bar.update(cx, |state, cx| {
247 let new_ix = if is_open { None } else { Some(self.ix) };
248 state.set_selected_index(new_ix, window, cx);
249 });
250 }
251
252 fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
253 if !*hovered {
254 return;
255 }
256
257 let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
258 if !has_activated_menu {
259 return;
260 }
261
262 _ = self.menu_bar.update(cx, |state, cx| {
263 state.set_selected_index(Some(self.ix), window, cx);
264 });
265 }
266}
267
268impl Render for AppMenu {
269 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
270 let is_open = self.is_open(cx);
271
272 div()
273 .id(self.ix)
274 .relative()
275 .child(
276 Button::new("menu")
277 .small()
278 .py_0p5()
279 .compact()
280 .ghost()
281 .label(self.name.clone())
282 .open(is_open)
283 .on_mouse_down(
284 MouseButton::Left,
285 window.listener_for(&cx.entity(), move |this, _, window, cx| {
286 window.prevent_default();
288 cx.stop_propagation();
289 this.toggle(window, cx);
290 }),
291 )
292 .on_click(cx.listener(Self::handle_trigger_click)),
293 )
294 .on_hover(cx.listener(Self::handle_hover))
295 .when(is_open, |this| {
296 this.child(deferred(
297 anchored()
298 .anchor(gpui::Anchor::TopLeft)
299 .snap_to_window_with_margin(px(8.))
300 .child(
301 div()
302 .size_full()
303 .occlude()
304 .top_1()
305 .child(self.build_popup_menu(window, cx)),
306 ),
307 ))
308 })
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 use gpui::TestAppContext;
317
318 struct TestRoot {
319 menu_bar: Entity<AppMenuBar>,
320 first_focus: FocusHandle,
321 second_focus: FocusHandle,
322 }
323
324 impl Render for TestRoot {
325 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
326 div()
327 .child(div().id("first").track_focus(&self.first_focus))
328 .child(div().id("second").track_focus(&self.second_focus))
329 .child(self.menu_bar.clone())
330 }
331 }
332
333 #[gpui::test]
334 fn preserves_action_context_while_switching_menus(cx: &mut TestAppContext) {
335 let (root, cx) = cx.add_window_view(|window, cx| {
336 let first_focus = cx.focus_handle();
337 let second_focus = cx.focus_handle();
338 first_focus.focus(window, cx);
339
340 TestRoot {
341 menu_bar: cx.new(|_| AppMenuBar {
342 menus: Vec::new(),
343 selected_index: None,
344 action_context: None,
345 scroll_handle: ScrollHandle::new(),
346 }),
347 first_focus,
348 second_focus,
349 }
350 });
351
352 let (menu_bar, first_focus, second_focus) = root.read_with(cx, |root, _| {
353 (
354 root.menu_bar.clone(),
355 root.first_focus.clone(),
356 root.second_focus.clone(),
357 )
358 });
359
360 menu_bar.update_in(cx, |menu_bar, window, cx| {
361 menu_bar.set_selected_index(Some(0), window, cx);
362 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
363
364 second_focus.focus(window, cx);
365 menu_bar.set_selected_index(Some(1), window, cx);
366 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
367
368 menu_bar.set_selected_index(None, window, cx);
369 assert!(menu_bar.action_context.is_none());
370 assert_eq!(window.focused(cx).as_ref(), Some(&first_focus));
371
372 second_focus.focus(window, cx);
373 menu_bar.set_selected_index(Some(0), window, cx);
374 assert_eq!(menu_bar.action_context.as_ref(), Some(&second_focus));
375 });
376 }
377}