Skip to main content

gpui_cn/menu/
bar.rs

1//! A menu bar in the window: a row of titles, each opening a menu under
2//! it, on the same state and panels as every other menu.
3
4use std::{
5    cell::RefCell,
6    collections::{HashMap, HashSet},
7    rc::Rc,
8};
9
10use gpui_kit::{
11    App, AppContext as _, Bounds, Context, ElementId, Entity, EventEmitter, FocusHandle,
12    InteractiveElement as _, IntoElement, MouseButton, OwnedMenu, ParentElement as _, Pixels,
13    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
14    Window,
15    base::{
16        Align, Disableable, ElementExt as _, GlobalState, Selectable as _, TestSupportExt as _,
17        actions::{SelectDown, SelectLeft, SelectRight},
18        h_flex,
19    },
20    div,
21    prelude::FluentBuilder as _,
22};
23
24use super::{BAR_CONTEXT, MenuAnchor, MenuEntry, MenuEvent, MenuState, panel::MenuPanels};
25use crate::{ActiveTheme as _, Button, ButtonSize};
26
27/// One menu of a [`MenuBar`]: its title and its rows.
28#[derive(Clone)]
29#[non_exhaustive]
30pub struct MenuBarMenu {
31    key: SharedString,
32    label: SharedString,
33    disabled: bool,
34    entries: Vec<MenuEntry>,
35}
36
37impl MenuBarMenu {
38    /// A menu with a stable key and the title it shows.
39    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
40        Self {
41            key: key.into(),
42            label: label.into(),
43            disabled: false,
44            entries: Vec::new(),
45        }
46    }
47
48    /// The menu's rows.
49    pub fn entries(mut self, entries: impl IntoIterator<Item = impl Into<MenuEntry>>) -> Self {
50        self.entries.extend(entries.into_iter().map(Into::into));
51        self
52    }
53
54    /// The menu's key.
55    pub fn key(&self) -> &SharedString {
56        &self.key
57    }
58
59    /// The title the bar shows.
60    pub fn label(&self) -> &SharedString {
61        &self.label
62    }
63
64    /// Whether the title ignores input.
65    pub fn is_disabled(&self) -> bool {
66        self.disabled
67    }
68}
69
70impl Disableable for MenuBarMenu {
71    fn disabled(mut self, disabled: bool) -> Self {
72        self.disabled = disabled;
73        self
74    }
75}
76
77/// A menu of GPUI's application menu data, keyed by its name, its rows
78/// keyed as [`MenuEntry::from_app_menu_item`] keys them, unique within
79/// the menu. Items the system owns are left out.
80impl From<OwnedMenu> for MenuBarMenu {
81    fn from(menu: OwnedMenu) -> Self {
82        let mut used = HashSet::new();
83        let entries: Vec<MenuEntry> = menu
84            .items
85            .into_iter()
86            .filter_map(|item| MenuEntry::from_app_item_keyed(item, &mut used))
87            .collect();
88        Self::new(menu.name.clone(), menu.name)
89            .disabled(menu.disabled)
90            .entries(entries)
91    }
92}
93
94/// The menus of a [`MenuBar`] and the one open among them.
95///
96/// Owned by the view that shows the bar, which observes it so a change
97/// renders. The open menu is a [`MenuState`], so its keyboard, dismissal,
98/// and focus are every menu's: Left and Right at its root move to the
99/// neighbouring menu. Items' actions go to the element that had focus
100/// before the bar opened, so Edit > Copy reaches the focused field.
101pub struct MenuBarState {
102    menus: Vec<MenuBarMenu>,
103    /// The menu that is open, by index, while the menu state is open.
104    open: Option<usize>,
105    menu: Entity<MenuState>,
106    /// The element focused before the bar opened, which its items act on.
107    context: Option<FocusHandle>,
108    /// Where each title was last laid out, by key.
109    titles: Rc<RefCell<HashMap<SharedString, Bounds<Pixels>>>>,
110    /// Each title's focus. Tab reaches the highlighted title only, and the
111    /// arrows move focus between titles.
112    title_focus: Vec<FocusHandle>,
113    /// The title Tab reaches and the arrows move from.
114    highlighted: usize,
115    _subscriptions: Vec<Subscription>,
116}
117
118impl EventEmitter<MenuEvent> for MenuBarState {}
119
120impl MenuBarState {
121    /// A bar over `menus`, all closed.
122    pub fn new(
123        menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
124        cx: &mut Context<Self>,
125    ) -> Self {
126        let menu = cx.new(MenuState::new);
127        let this = cx.weak_entity();
128        menu.update(cx, |menu, _| {
129            menu.set_step_handler(Rc::new(move |delta, window, cx| {
130                this.update(cx, |bar, cx| bar.step(delta, window, cx)).ok();
131            }))
132        });
133        let subscriptions = vec![
134            cx.observe(&menu, |_, _, cx| cx.notify()),
135            cx.subscribe(&menu, |this, menu, event: &MenuEvent, cx| {
136                // A switch closes one menu and opens the next in one turn,
137                // so the close only counts when the menu stayed closed.
138                if *event == MenuEvent::Closed && !menu.read(cx).is_open() {
139                    this.open = None;
140                    this.context = None;
141                }
142                cx.emit(event.clone());
143                cx.notify();
144            }),
145        ];
146        let menus: Vec<MenuBarMenu> = menus.into_iter().map(Into::into).collect();
147        let title_focus = menus.iter().map(|_| cx.focus_handle()).collect();
148        Self {
149            menus,
150            open: None,
151            menu,
152            context: None,
153            titles: Rc::default(),
154            title_focus,
155            highlighted: 0,
156            _subscriptions: subscriptions,
157        }
158    }
159
160    /// A bar over the application's menus, as given to `App::set_menus`;
161    /// empty where the platform keeps none.
162    pub fn from_app_menus(cx: &mut Context<Self>) -> Self {
163        let menus = cx.get_menus().unwrap_or_default();
164        Self::new(menus, cx)
165    }
166
167    /// The menus, in bar order.
168    pub fn menus(&self) -> &[MenuBarMenu] {
169        &self.menus
170    }
171
172    /// Replaces the menus, closing the open one.
173    pub fn set_menus(
174        &mut self,
175        menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
176        window: &mut Window,
177        cx: &mut Context<Self>,
178    ) {
179        self.close(window, cx);
180        self.menus = menus.into_iter().map(Into::into).collect();
181        self.title_focus = self.menus.iter().map(|_| cx.focus_handle()).collect();
182        self.highlighted = self.highlighted.min(self.menus.len().saturating_sub(1));
183        cx.notify();
184    }
185
186    /// The key of the open menu.
187    pub fn open_menu(&self) -> Option<&SharedString> {
188        Some(self.menus.get(self.open?)?.key())
189    }
190
191    /// Whether a menu is open.
192    pub fn is_open(&self) -> bool {
193        self.open.is_some()
194    }
195
196    /// The open menu's state.
197    pub fn menu(&self) -> &Entity<MenuState> {
198        &self.menu
199    }
200
201    /// Opens the menu with `key` under its title.
202    pub fn open(&mut self, key: &str, window: &mut Window, cx: &mut Context<Self>) {
203        if let Some(index) = self.menus.iter().position(|menu| menu.key == key) {
204            self.open_at(index, false, window, cx);
205        }
206    }
207
208    /// Closes the open menu and gives focus back.
209    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
210        self.menu.update(cx, |menu, cx| menu.close(window, cx));
211    }
212
213    /// Opens the menu at `index`, with the keyboard on its first row when
214    /// the keyboard opened it.
215    fn open_at(
216        &mut self,
217        index: usize,
218        keyboard: bool,
219        window: &mut Window,
220        cx: &mut Context<Self>,
221    ) {
222        let Some(menu) = self.menus.get(index).filter(|menu| !menu.disabled) else {
223            return;
224        };
225        if !self.menu.read(cx).is_open() {
226            self.context = window.focused(cx);
227        }
228        let bounds = self
229            .titles
230            .borrow()
231            .get(&menu.key)
232            .copied()
233            .unwrap_or_default();
234        let anchor = MenuAnchor::Trigger(bounds, Align::Start);
235        let entries = menu.entries.clone();
236        let context = self.context.clone();
237        self.menu.update(cx, |menu, cx| {
238            match &context {
239                Some(context) => menu.open_for(entries, anchor, context, window, cx),
240                None => menu.open(entries, anchor, window, cx),
241            }
242            if keyboard {
243                menu.highlight_edge(false, cx);
244            }
245        });
246        self.open = Some(index);
247        self.highlighted = index;
248        cx.notify();
249    }
250
251    /// Moves focus to the next (or previous) title that takes input,
252    /// wrapping.
253    fn move_highlight(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
254        if let Some(index) = self.neighbour(self.highlighted, delta) {
255            self.highlighted = index;
256            self.title_focus[index].focus(window, cx);
257            cx.notify();
258        }
259    }
260
261    /// Opens the title the keyboard is on, with the keyboard on its first
262    /// row.
263    fn open_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
264        self.open_at(self.highlighted, true, window, cx);
265    }
266
267    /// The next title from `from` that takes input, `delta` steps at a
268    /// time, wrapping; none when every other title is disabled.
269    fn neighbour(&self, from: usize, delta: isize) -> Option<usize> {
270        let count = self.menus.len() as isize;
271        let mut index = from as isize;
272        for _ in 0..count {
273            index = (index + delta).rem_euclid(count);
274            if !self.menus[index as usize].disabled {
275                return Some(index as usize);
276            }
277        }
278        None
279    }
280
281    /// Moves to the next (or previous) menu that takes input, wrapping.
282    fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
283        let Some(open) = self.open else {
284            return;
285        };
286        if let Some(index) = self.neighbour(open, delta).filter(|index| *index != open) {
287            self.open_at(index, true, window, cx);
288        }
289    }
290}
291
292/// A row of menu titles in the window, as a desktop application's menu
293/// bar, for a platform or a layout without the native one.
294///
295/// A press or a tap on a title opens its menu under it and a second one
296/// closes it; while a menu is open, the pointer moving onto another
297/// title opens that one instead. The open menu is every menu: arrows,
298/// Enter, Escape, submenus, and a press outside behave as in a
299/// [`DropdownMenu`](super::DropdownMenu).
300///
301/// ```
302/// use gpui_cn::{MenuBar, MenuBarMenu, MenuBarState, MenuItem};
303/// use gpui_kit::{AppContext as _, Context, Entity, IntoElement};
304///
305/// fn bar(cx: &mut Context<()>) -> Entity<MenuBarState> {
306///     cx.new(|cx| {
307///         MenuBarState::new(
308///             [MenuBarMenu::new("file", "File").entries([MenuItem::new("new", "New File")])],
309///             cx,
310///         )
311///     })
312/// }
313///
314/// fn render(state: &Entity<MenuBarState>) -> impl IntoElement {
315///     MenuBar::new("menu-bar", state)
316/// }
317/// ```
318///
319/// The id keys the titles and the rows: a title is its menu's key under
320/// the id, and the rows are under `"menus"` under the id.
321#[derive(IntoElement)]
322pub struct MenuBar {
323    id: ElementId,
324    state: Entity<MenuBarState>,
325    plain: bool,
326}
327
328impl MenuBar {
329    /// A menu bar on `state`, with a stable id.
330    pub fn new(id: impl Into<ElementId>, state: &Entity<MenuBarState>) -> Self {
331        Self {
332            id: id.into(),
333            state: state.clone(),
334            plain: false,
335        }
336    }
337
338    /// Drops the frame (the fill, the hairline, and the inset) for a bar
339    /// in a title bar or a toolbar that gives it a surface already.
340    pub fn plain(mut self) -> Self {
341        self.plain = true;
342        self
343    }
344}
345
346impl RenderOnce for MenuBar {
347    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
348        let theme = cx.theme();
349        let (frame_fill, frame_border, frame_radius, touch) = (
350            theme.select_trigger,
351            theme.select_trigger_border,
352            ButtonSize::Default.geometry(theme).radius,
353            theme.touch,
354        );
355        // Not a ghost button's muted rest, which reads as disabled; and
356        // not its selected fill, which vanishes on the dark bar.
357        let (title_ink, open_fill) = (theme.foreground(), theme.popover_accent);
358        let (menus, open, menu, titles, title_focus, highlighted) = {
359            let state = self.state.read(cx);
360            let open = state.open.filter(|_| state.menu.read(cx).is_open());
361            (
362                state.menus.clone(),
363                open,
364                state.menu.clone(),
365                state.titles.clone(),
366                state.title_focus.clone(),
367                state.highlighted,
368            )
369        };
370        let bar_id = self.id.clone();
371        let on = |f: fn(&mut MenuBarState, &mut Window, &mut Context<MenuBarState>)| {
372            let state = self.state.clone();
373            move |window: &mut Window, cx: &mut App| {
374                state.update(cx, |state, cx| f(state, window, cx))
375            }
376        };
377        let (left, right, down) = (
378            on(|state, window, cx| state.move_highlight(-1, window, cx)),
379            on(|state, window, cx| state.move_highlight(1, window, cx)),
380            on(|state, window, cx| state.open_highlighted(window, cx)),
381        );
382        h_flex()
383            .id(self.id.clone())
384            .test_support()
385            .key_context(BAR_CONTEXT)
386            .role(Role::MenuBar)
387            .when(open.is_none(), |this| {
388                this.on_action(move |_: &SelectLeft, window, cx| left(window, cx))
389                    .on_action(move |_: &SelectRight, window, cx| right(window, cx))
390                    .on_action(move |_: &SelectDown, window, cx| down(window, cx))
391            })
392            .flex_shrink_0()
393            .items_center()
394            .when(!self.plain, |this| {
395                this.p_1()
396                    .rounded(frame_radius)
397                    .bg(frame_fill)
398                    .border_1()
399                    .border_color(frame_border)
400            })
401            .children(menus.into_iter().enumerate().map(|(index, entry)| {
402                let is_open = open == Some(index);
403                let disabled = entry.disabled;
404                let key = entry.key.clone();
405                let titles = titles.clone();
406                let press = self.state.clone();
407                let hover = self.state.clone();
408                let keyboard = self.state.clone();
409                // Opens on the press, so a press can drag on into the
410                // menu; the click comes on release and acts only for Enter
411                // and Space.
412                let title = Button::new(ElementId::NamedChild(bar_id.clone().into(), key.clone()))
413                    .ghost()
414                    .size(ButtonSize::Default)
415                    .label(entry.label.clone())
416                    .accessibility_label(entry.label)
417                    .with_role(Role::MenuItem)
418                    .with_expanded(is_open)
419                    .open(is_open)
420                    .disabled(disabled)
421                    .when(!disabled, |this| this.text_color(title_ink))
422                    .when(is_open, |this| this.bg(open_fill))
423                    .track_focus(&title_focus[index])
424                    .tab_stop(index == highlighted)
425                    .on_click(move |event, window, cx| {
426                        if event.is_keyboard() {
427                            keyboard.update(cx, |state, cx| {
428                                state.highlighted = index;
429                                state.open_at(index, true, window, cx)
430                            });
431                        }
432                    })
433                    .when(!disabled, |this| {
434                        this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
435                            // The press is the title's: focus stays where it
436                            // is, so the items act on it.
437                            window.prevent_default();
438                            cx.stop_propagation();
439                            GlobalState::suppress_text_selection(cx);
440                            // An open menu's outside press closed it already;
441                            // a press on its own title leaves it closed.
442                            if open == Some(index) {
443                                press.update(cx, |state, cx| state.close(window, cx));
444                            } else {
445                                press.update(cx, |state, cx| {
446                                    state.open_at(index, false, window, cx)
447                                });
448                            }
449                        })
450                        .when(!touch, |this| {
451                            this.on_mouse_move(move |_, window, cx| {
452                                let switch = {
453                                    let state = hover.read(cx);
454                                    state.menu.read(cx).is_open() && state.open != Some(index)
455                                };
456                                if switch {
457                                    hover.update(cx, |state, cx| {
458                                        state.open_at(index, false, window, cx)
459                                    });
460                                }
461                            })
462                        })
463                    });
464                // The title's bounds, which its menu opens under, come from
465                // a box around the button, the button's own size.
466                div()
467                    .relative()
468                    .flex_shrink_0()
469                    .child(title)
470                    .on_prepaint(move |bounds, _, _| {
471                        titles.borrow_mut().insert(key, bounds);
472                    })
473            }))
474            .child(MenuPanels::new(
475                ElementId::NamedChild(self.id.into(), "menus".into()),
476                &menu,
477            ))
478    }
479}