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};
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
25/// The application menu bar, for Windows and Linux.
26pub struct AppMenuBar {
27    menus: Vec<Entity<AppMenu>>,
28    selected_index: Option<usize>,
29    action_context: Option<FocusHandle>,
30}
31
32impl AppMenuBar {
33    /// Create a new app menu bar.
34    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    /// Reload the menus from the app.
47    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
136/// A menu in the menu bar.
137pub(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    /// Whether this menu's popup is the one the menu bar currently has open.
166    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                            // Stop propagation to avoid dragging the window.
277                            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}