Skip to main content

gpui_component/menu/
app_menu_bar.rs

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
26/// The application menu bar, for Windows and Linux.
27pub 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    /// Create a new app menu bar.
36    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    /// Reload the menus from the app.
50    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
146/// A menu in the menu bar.
147pub(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    /// Whether this menu's popup is the one the menu bar currently has open.
176    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                            // Stop propagation to avoid dragging the window.
287                            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}