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_selected(&self, cx: &App) -> bool {
166 self.menu_bar.read(cx).selected_index == Some(self.ix)
167 }
168
169 fn build_popup_menu(
170 &mut self,
171 window: &mut Window,
172 cx: &mut Context<Self>,
173 ) -> Entity<PopupMenu> {
174 let action_context = self.menu_bar.read(cx).action_context.clone();
175 let popup_menu = match self.popup_menu.as_ref() {
176 None => {
177 let items = self.menu.items.clone();
178 let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
179 menu.with_menu_items(items, window, cx)
180 });
181 popup_menu.update(cx, |menu, cx| {
182 menu.set_action_context(action_context.clone(), cx);
183 });
184 self._subscription =
185 Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
186 self.popup_menu = Some(popup_menu.clone());
187
188 popup_menu
189 }
190 Some(menu) => {
191 menu.update(cx, |menu, cx| {
192 menu.set_action_context(action_context.clone(), cx);
193 });
194 menu.clone()
195 }
196 };
197
198 let focus_handle = popup_menu.read(cx).focus_handle(cx);
199 if !focus_handle.contains_focused(window, cx) {
200 focus_handle.focus(window, cx);
201 }
202
203 popup_menu
204 }
205
206 fn handle_dismiss(
207 &mut self,
208 _: &Entity<PopupMenu>,
209 _: &DismissEvent,
210 window: &mut Window,
211 cx: &mut Context<Self>,
212 ) {
213 self._subscription.take();
214 self.popup_menu.take();
215 self.menu_bar.update(cx, |state, cx| {
216 state.on_cancel(&Cancel, window, cx);
217 });
218 }
219
220 fn handle_trigger_click(
221 &mut self,
222 event: &ClickEvent,
223 window: &mut Window,
224 cx: &mut Context<Self>,
225 ) {
226 if matches!(event, ClickEvent::Mouse(_)) {
227 return;
228 }
229
230 self.toggle(window, cx);
231 }
232
233 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
234 let is_selected = self.is_selected(cx);
235 _ = self.menu_bar.update(cx, |state, cx| {
236 let new_ix = if is_selected { None } else { Some(self.ix) };
237 state.set_selected_index(new_ix, window, cx);
238 });
239 }
240
241 fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
242 if !*hovered {
243 return;
244 }
245
246 let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
247 if !has_activated_menu {
248 return;
249 }
250
251 _ = self.menu_bar.update(cx, |state, cx| {
252 state.set_selected_index(Some(self.ix), window, cx);
253 });
254 }
255}
256
257impl Render for AppMenu {
258 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
259 let is_selected = self.is_selected(cx);
260
261 div()
262 .id(self.ix)
263 .relative()
264 .child(
265 Button::new("menu")
266 .small()
267 .py_0p5()
268 .compact()
269 .ghost()
270 .label(self.name.clone())
271 .selected(is_selected)
272 .on_mouse_down(
273 MouseButton::Left,
274 window.listener_for(&cx.entity(), move |this, _, window, cx| {
275 window.prevent_default();
277 cx.stop_propagation();
278 this.toggle(window, cx);
279 }),
280 )
281 .on_click(cx.listener(Self::handle_trigger_click)),
282 )
283 .on_hover(cx.listener(Self::handle_hover))
284 .when(is_selected, |this| {
285 this.child(deferred(
286 anchored()
287 .anchor(gpui::Anchor::TopLeft)
288 .snap_to_window_with_margin(px(8.))
289 .child(
290 div()
291 .size_full()
292 .occlude()
293 .top_1()
294 .child(self.build_popup_menu(window, cx)),
295 ),
296 ))
297 })
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 use gpui::TestAppContext;
306
307 struct TestRoot {
308 menu_bar: Entity<AppMenuBar>,
309 first_focus: FocusHandle,
310 second_focus: FocusHandle,
311 }
312
313 impl Render for TestRoot {
314 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
315 div()
316 .child(div().id("first").track_focus(&self.first_focus))
317 .child(div().id("second").track_focus(&self.second_focus))
318 .child(self.menu_bar.clone())
319 }
320 }
321
322 #[gpui::test]
323 fn preserves_action_context_while_switching_menus(cx: &mut TestAppContext) {
324 let (root, cx) = cx.add_window_view(|window, cx| {
325 let first_focus = cx.focus_handle();
326 let second_focus = cx.focus_handle();
327 first_focus.focus(window, cx);
328
329 TestRoot {
330 menu_bar: cx.new(|_| AppMenuBar {
331 menus: Vec::new(),
332 selected_index: None,
333 action_context: None,
334 }),
335 first_focus,
336 second_focus,
337 }
338 });
339
340 let (menu_bar, first_focus, second_focus) = root.read_with(cx, |root, _| {
341 (
342 root.menu_bar.clone(),
343 root.first_focus.clone(),
344 root.second_focus.clone(),
345 )
346 });
347
348 menu_bar.update_in(cx, |menu_bar, window, cx| {
349 menu_bar.set_selected_index(Some(0), window, cx);
350 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
351
352 second_focus.focus(window, cx);
353 menu_bar.set_selected_index(Some(1), window, cx);
354 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
355
356 menu_bar.set_selected_index(None, window, cx);
357 assert!(menu_bar.action_context.is_none());
358 assert_eq!(window.focused(cx).as_ref(), Some(&first_focus));
359
360 second_focus.focus(window, cx);
361 menu_bar.set_selected_index(Some(0), window, cx);
362 assert_eq!(menu_bar.action_context.as_ref(), Some(&second_focus));
363 });
364 }
365}