Skip to main content

rgpui_component/menu/
app_menu_bar.rs

1use crate::{
2    Selectable, Sizable,
3    actions::{Cancel, SelectLeft, SelectRight},
4    button::{Button, ButtonVariants},
5    global_state::GlobalState,
6    h_flex,
7    menu::PopupMenu,
8};
9use rgpui::{
10    App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, FocusHandle, Focusable,
11    InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
12    Render, 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            .key_context(CONTEXT)
124            .on_action(cx.listener(Self::on_move_left))
125            .on_action(cx.listener(Self::on_move_right))
126            .on_action(cx.listener(Self::on_cancel))
127            .size_full()
128            .gap_x_1()
129            .overflow_x_scroll()
130            .children(self.menus.clone())
131    }
132}
133
134/// A menu in the menu bar.
135pub(super) struct AppMenu {
136    menu_bar: Entity<AppMenuBar>,
137    ix: usize,
138    name: SharedString,
139    menu: OwnedMenu,
140    popup_menu: Option<Entity<PopupMenu>>,
141
142    _subscription: Option<Subscription>,
143}
144
145impl AppMenu {
146    pub(super) fn new(
147        ix: usize,
148        menu: &OwnedMenu,
149        menu_bar: Entity<AppMenuBar>,
150        cx: &mut App,
151    ) -> Entity<Self> {
152        let name = menu.name.clone();
153        cx.new(|_| Self {
154            ix,
155            menu_bar,
156            name,
157            menu: menu.clone(),
158            popup_menu: None,
159            _subscription: None,
160        })
161    }
162
163    fn build_popup_menu(
164        &mut self,
165        window: &mut Window,
166        cx: &mut Context<Self>,
167    ) -> Entity<PopupMenu> {
168        let action_context = self.menu_bar.read(cx).action_context.clone();
169        let popup_menu = match self.popup_menu.as_ref() {
170            None => {
171                let items = self.menu.items.clone();
172                let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
173                    menu.with_menu_items(items, window, cx)
174                });
175                popup_menu.update(cx, |menu, cx| {
176                    menu.set_action_context(action_context.clone(), cx);
177                });
178                self._subscription =
179                    Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
180                self.popup_menu = Some(popup_menu.clone());
181
182                popup_menu
183            }
184            Some(menu) => {
185                menu.update(cx, |menu, cx| {
186                    menu.set_action_context(action_context.clone(), cx);
187                });
188                menu.clone()
189            }
190        };
191
192        let focus_handle = popup_menu.read(cx).focus_handle(cx);
193        if !focus_handle.contains_focused(window, cx) {
194            focus_handle.focus(window, cx);
195        }
196
197        popup_menu
198    }
199
200    fn handle_dismiss(
201        &mut self,
202        _: &Entity<PopupMenu>,
203        _: &DismissEvent,
204        window: &mut Window,
205        cx: &mut Context<Self>,
206    ) {
207        self._subscription.take();
208        self.popup_menu.take();
209        self.menu_bar.update(cx, |state, cx| {
210            state.on_cancel(&Cancel, window, cx);
211        });
212    }
213
214    fn handle_trigger_click(
215        &mut self,
216        _: &ClickEvent,
217        window: &mut Window,
218        cx: &mut Context<Self>,
219    ) {
220        let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix);
221
222        _ = self.menu_bar.update(cx, |state, cx| {
223            let new_ix = if is_selected { None } else { Some(self.ix) };
224            state.set_selected_index(new_ix, window, cx);
225        });
226    }
227
228    fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
229        if !*hovered {
230            return;
231        }
232
233        let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
234        if !has_activated_menu {
235            return;
236        }
237
238        _ = self.menu_bar.update(cx, |state, cx| {
239            state.set_selected_index(Some(self.ix), window, cx);
240        });
241    }
242}
243
244impl Render for AppMenu {
245    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
246        let menu_bar = self.menu_bar.read(cx);
247        let is_selected = menu_bar.selected_index == Some(self.ix);
248
249        div()
250            .id(self.ix)
251            .relative()
252            .child(
253                Button::new("menu")
254                    .small()
255                    .py_0p5()
256                    .compact()
257                    .ghost()
258                    .label(self.name.clone())
259                    .selected(is_selected)
260                    .on_mouse_down(MouseButton::Left, |_, window, cx| {
261                        // Stop propagation to avoid dragging the window.
262                        window.prevent_default();
263                        cx.stop_propagation();
264                    })
265                    .on_click(cx.listener(Self::handle_trigger_click)),
266            )
267            .on_hover(cx.listener(Self::handle_hover))
268            .when(is_selected, |this| {
269                this.child(deferred(
270                    anchored()
271                        .anchor(rgpui::Anchor::TopLeft)
272                        .snap_to_window_with_margin(px(8.))
273                        .child(
274                            div()
275                                .size_full()
276                                .occlude()
277                                .top_1()
278                                .child(self.build_popup_menu(window, cx)),
279                        ),
280                ))
281            })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    use rgpui::TestAppContext;
290
291    struct TestRoot {
292        menu_bar: Entity<AppMenuBar>,
293        first_focus: FocusHandle,
294        second_focus: FocusHandle,
295    }
296
297    impl Render for TestRoot {
298        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
299            div()
300                .child(div().id("first").track_focus(&self.first_focus))
301                .child(div().id("second").track_focus(&self.second_focus))
302                .child(self.menu_bar.clone())
303        }
304    }
305
306    #[rgpui::test]
307    fn preserves_action_context_while_switching_menus(cx: &mut TestAppContext) {
308        let (root, cx) = cx.add_window_view(|window, cx| {
309            let first_focus = cx.focus_handle();
310            let second_focus = cx.focus_handle();
311            first_focus.focus(window, cx);
312
313            TestRoot {
314                menu_bar: cx.new(|_| AppMenuBar {
315                    menus: Vec::new(),
316                    selected_index: None,
317                    action_context: None,
318                }),
319                first_focus,
320                second_focus,
321            }
322        });
323
324        let (menu_bar, first_focus, second_focus) = root.read_with(cx, |root, _| {
325            (
326                root.menu_bar.clone(),
327                root.first_focus.clone(),
328                root.second_focus.clone(),
329            )
330        });
331
332        menu_bar.update_in(cx, |menu_bar, window, cx| {
333            menu_bar.set_selected_index(Some(0), window, cx);
334            assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
335
336            second_focus.focus(window, cx);
337            menu_bar.set_selected_index(Some(1), window, cx);
338            assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
339
340            menu_bar.set_selected_index(None, window, cx);
341            assert!(menu_bar.action_context.is_none());
342            assert_eq!(window.focused(cx).as_ref(), Some(&first_focus));
343
344            second_focus.focus(window, cx);
345            menu_bar.set_selected_index(Some(0), window, cx);
346            assert_eq!(menu_bar.action_context.as_ref(), Some(&second_focus));
347        });
348    }
349}