Skip to main content

gpui_kit/overlay/
menubar.rs

1//! The horizontal row of menus along the top of a window's content.
2//!
3//! # What this adds, and what it reuses
4//!
5//! Everything inside an open menu — rows, checkable items, separators, section
6//! labels, submenus, type-ahead, up and down, escape folding one submenu away
7//! at a time — is [`Menu`], unchanged. A menubar holds one [`Menu`] view per
8//! title and coordinates them; it does not reimplement a single row.
9//!
10//! What it adds is the three behaviours that only exist because the menus sit
11//! in a row:
12//!
13//! 1. **At most one is open.** Opening a menu closes whichever was open.
14//! 2. **Hover switches, but only once one is open.** Before that, hovering a
15//!    title does nothing, because a menubar that opened on hover from cold
16//!    would ambush anybody whose pointer crossed it on the way somewhere else.
17//!    After that, the row behaves as one surface and moving along it moves the
18//!    open menu, with no second click.
19//! 3. **The reading-order arrows step between titles.** Left and right are
20//!    read through [`LayoutDirection::arrow_step`](crate::foundation::direction::LayoutDirection::arrow_step), so in a right-to-left
21//!    layout the arrow that points at the next title is the one that opens it.
22//!
23//! Escape needs nothing added: [`Menu`] already closes and hands the keyboard
24//! back to its own trigger, and the menubar learns the menu closed from the
25//! event rather than by being told twice.
26//!
27//! # Why the arrows do not fight the submenus
28//!
29//! Left and right already mean "enter this submenu" and "leave it" inside an
30//! open menu. So the menubar takes them on the way back up rather than on the
31//! way down: [`Menu`] stops a sideways key that actually moved through a
32//! submenu and declines one that found no submenu to move through, and only a
33//! declined key reaches the row. The deeper surface gets first refusal, which
34//! is the only ordering under which "right opens the submenu" and "right opens
35//! the next menu" can both be true of the same key.
36//!
37//! The row has ends rather than wrapping, which is the rule every strip in
38//! this library keeps. Arrowing off the last title stops there instead of
39//! silently reappearing at the other end of the window.
40
41use gpui::{
42    AppContext as _, Context, Entity, EventEmitter, InteractiveElement, IntoElement, KeyDownEvent,
43    ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, div,
44};
45use gpui_kit_semantics::{NodeSpec, Role, Semantic};
46use gpui_kit_theme::{ActiveTheme, ControlSize, Space};
47
48use crate::controls::button::{Button, ButtonVariant};
49use crate::foundation::direction::{ActiveDirection, DirectionalExt};
50use crate::foundation::stepping::bounded_step;
51use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
52use crate::overlay::menu::{Menu, MenuEvent, MenuItem};
53
54/// One title in the bar and the commands under it.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct MenubarMenu {
57    id: SharedString,
58    label: SharedString,
59    items: Vec<MenuItem>,
60    disabled: bool,
61}
62
63impl MenubarMenu {
64    pub fn new(
65        id: impl Into<SharedString>,
66        label: impl Into<SharedString>,
67        items: impl IntoIterator<Item = MenuItem>,
68    ) -> Self {
69        Self {
70            id: id.into(),
71            label: label.into(),
72            items: items.into_iter().collect(),
73            disabled: false,
74        }
75    }
76
77    /// Refuses the whole title. A refused title installs no handler, opens on
78    /// no hover, and the arrows step over it.
79    pub fn disabled(mut self, disabled: bool) -> Self {
80        self.disabled = disabled;
81        self
82    }
83
84    pub fn id(&self) -> &SharedString {
85        &self.id
86    }
87
88    pub fn label(&self) -> &SharedString {
89        &self.label
90    }
91
92    pub fn is_disabled(&self) -> bool {
93        self.disabled
94    }
95}
96
97/// What a menubar reports. The owner decides what any of it means.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum MenubarEvent {
100    Opened(SharedString),
101    /// A row was taken, named together with the menu it came from, because two
102    /// menus may offer the same command and the host may care which one asked.
103    Invoked {
104        menu: SharedString,
105        item: SharedString,
106    },
107    Closed(SharedString),
108}
109
110impl EventEmitter<MenubarEvent> for Menubar {}
111
112/// A row of menus, at most one of them open.
113pub struct Menubar {
114    ident: Ident,
115    menus: Vec<MenubarMenu>,
116    /// One view per title, absent for a title the host refused: a refused
117    /// title is a plain disabled button with no menu behind it at all.
118    views: Vec<Option<Entity<Menu>>>,
119    open: Option<SharedString>,
120    size: ControlSize,
121}
122
123impl std::fmt::Debug for Menubar {
124    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        formatter
126            .debug_struct("Menubar")
127            .field("ident", &self.ident)
128            .field("menus", &self.menus.len())
129            .field("open", &self.open)
130            .finish()
131    }
132}
133
134impl Menubar {
135    pub fn new(
136        ident: impl Into<Ident>,
137        menus: impl IntoIterator<Item = MenubarMenu>,
138        window: &mut Window,
139        cx: &mut Context<Self>,
140    ) -> Self {
141        let ident = ident.into();
142        let menus: Vec<MenubarMenu> = menus.into_iter().collect();
143        let mut bar = Self {
144            ident,
145            menus,
146            views: Vec::new(),
147            open: None,
148            size: ControlSize::Sm,
149        };
150        bar.build_views(window, cx);
151        bar
152    }
153
154    pub fn control_size(mut self, size: ControlSize) -> Self {
155        self.size = size;
156        self
157    }
158
159    /// The menu standing open, if any.
160    pub fn open_menu(&self) -> Option<&SharedString> {
161        self.open.as_ref()
162    }
163
164    pub fn menus(&self) -> &[MenubarMenu] {
165        &self.menus
166    }
167
168    /// Replaces the titles, closing anything that was open.
169    pub fn set_menus(
170        &mut self,
171        menus: Vec<MenubarMenu>,
172        window: &mut Window,
173        cx: &mut Context<Self>,
174    ) {
175        self.close(window, cx);
176        self.menus = menus;
177        self.build_views(window, cx);
178        cx.notify();
179    }
180
181    fn build_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
182        let ident = self.ident.clone();
183        let size = self.size;
184        let menus = self.menus.clone();
185        let mut views = Vec::with_capacity(menus.len());
186        for menu in &menus {
187            if menu.disabled {
188                views.push(None);
189                continue;
190            }
191            let id = menu.id.clone();
192            let view = cx.new(|cx| {
193                Menu::new(ident.child(id.as_ref()), window, cx)
194                    .trigger(menu.label.clone())
195                    .trigger_variant(ButtonVariant::Ghost)
196                    .control_size(size)
197                    .items(menu.items.clone())
198            });
199            cx.subscribe(&view, {
200                let id = id.clone();
201                move |bar, _, event: &MenuEvent, cx| bar.on_menu_event(&id, event, cx)
202            })
203            .detach();
204            views.push(Some(view));
205        }
206        self.views = views;
207    }
208
209    fn on_menu_event(&mut self, id: &SharedString, event: &MenuEvent, cx: &mut Context<Self>) {
210        match event {
211            MenuEvent::Opened => {
212                self.open = Some(id.clone());
213                cx.emit(MenubarEvent::Opened(id.clone()));
214                cx.notify();
215            }
216            MenuEvent::Closed => {
217                // Only the menu the bar still believes is open clears it: a
218                // sibling closing on its way out of the way must not erase the
219                // record of the one that just replaced it.
220                if self.open.as_ref() == Some(id) {
221                    self.open = None;
222                }
223                cx.emit(MenubarEvent::Closed(id.clone()));
224                cx.notify();
225            }
226            MenuEvent::Invoked(item) => cx.emit(MenubarEvent::Invoked {
227                menu: id.clone(),
228                item: item.clone(),
229            }),
230            MenuEvent::Dismissed => {}
231        }
232    }
233
234    fn index_of(&self, id: &SharedString) -> Option<usize> {
235        self.menus.iter().position(|menu| &menu.id == id)
236    }
237
238    fn view_for(&self, id: &SharedString) -> Option<&Entity<Menu>> {
239        self.index_of(id)
240            .and_then(|index| self.views[index].as_ref())
241    }
242
243    /// Opens one title, closing whatever stood open. A title the host refused
244    /// has no menu and opens nothing.
245    ///
246    /// The old menu is closed before the new one opens rather than in reaction
247    /// to it, because closing hands the keyboard back to its own trigger, and
248    /// doing that after the new menu had taken focus would pull the keyboard
249    /// back out of the menu the typist just opened.
250    pub fn open(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
251        let wanted = SharedString::from(id.to_string());
252        let Some(view) = self.view_for(&wanted).cloned() else {
253            return;
254        };
255        if let Some(open) = self.open.clone().filter(|open| open != &wanted) {
256            self.close_menu(&open, window, cx);
257        }
258        view.update(cx, |menu, cx| menu.open(window, cx));
259    }
260
261    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
262        let Some(open) = self.open.clone() else {
263            return;
264        };
265        self.close_menu(&open, window, cx);
266    }
267
268    fn close_menu(&mut self, id: &SharedString, window: &mut Window, cx: &mut Context<Self>) {
269        let Some(view) = self.view_for(id).cloned() else {
270            return;
271        };
272        view.update(cx, |menu, cx| menu.close(window, cx));
273    }
274
275    /// Hovering a title once one is open moves the open menu onto it, which is
276    /// what a row of menus does everywhere and what a row of buttons does
277    /// nowhere.
278    fn on_hover_title(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
279        let Some(open) = self.open.clone() else {
280            return;
281        };
282        let Some(menu) = self.menus.get(index) else {
283            return;
284        };
285        if menu.disabled || menu.id == open {
286            return;
287        }
288        let id = menu.id.clone();
289        self.open(id.as_ref(), window, cx);
290    }
291
292    /// Steps to the next title, but only with a key the open menu declined.
293    ///
294    /// This runs on the way back up, after the open menu has had the key, so a
295    /// right that entered a submenu never reaches here and a right that found
296    /// no submenu to enter does.
297    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
298        let Some(open) = self.open.clone() else {
299            return;
300        };
301        let Some(step) = cx
302            .layout_direction()
303            .arrow_step(event.keystroke.key.as_str())
304        else {
305            return;
306        };
307        let from = self.index_of(&open);
308        let refused = |index: usize| self.menus[index].disabled;
309        let Some(next) = bounded_step(self.menus.len(), from, step as isize, refused) else {
310            return;
311        };
312        let id = self.menus[next].id.clone();
313        self.open(id.as_ref(), window, cx);
314        cx.stop_propagation();
315    }
316}
317
318impl Sizable for Menubar {
319    fn control_size(mut self, size: ControlSize) -> Self {
320        self.size = size;
321        self
322    }
323}
324
325impl Render for Menubar {
326    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
327        let theme = cx.theme().clone();
328        let direction = cx.layout_direction();
329        let bar_id = self.ident.semantic_id();
330
331        let titles = self
332            .menus
333            .iter()
334            .enumerate()
335            .map(|(index, menu)| {
336                let ident = self.ident.child(menu.id.as_ref());
337                match &self.views[index] {
338                    Some(view) => div()
339                        .id(ident.child("title").element_id())
340                        .flex()
341                        .flex_none()
342                        .on_hover(cx.listener(move |bar, hovered: &bool, window, cx| {
343                            if *hovered {
344                                bar.on_hover_title(index, window, cx);
345                            }
346                        }))
347                        .child(view.clone())
348                        .into_any_element(),
349                    // A refused title installs nothing: no menu, no hover, no
350                    // click handler, so there is no path by which it can open.
351                    None => Button::new(ident)
352                        .label(menu.label.clone())
353                        .ghost()
354                        .control_size(self.size)
355                        .disabled(true)
356                        .semantic_parent(bar_id.clone())
357                        .into_any_element(),
358                }
359            })
360            .collect::<Vec<_>>();
361
362        div()
363            .id(self.ident.element_id())
364            .row_reading(direction)
365            .flex_none()
366            .gap_token(&theme, Space::Xs)
367            .on_key_down(cx.listener(Self::on_key))
368            .children(titles)
369            .semantic_in(
370                cx,
371                NodeSpec::new(bar_id, Role::Toolbar).expanded(self.open.is_some()),
372            )
373    }
374}