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