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    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                            // Stop propagation to avoid dragging the window.
276                            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}