Skip to main content

ui/components/
menubar.rs

1use gpui::{Anchor, Context, Entity, Render};
2
3use crate::{ContextMenu, PopoverMenu, PopoverMenuHandle, prelude::*};
4
5struct MenubarItem {
6    label: SharedString,
7    menu: Entity<ContextMenu>,
8    handle: PopoverMenuHandle<ContextMenu>,
9}
10
11/// A horizontal menubar with dropdown menus. Only one menu is open at a time;
12/// left/right arrow keys move between adjacent top-level menus while any menu
13/// is open.
14///
15/// Stateful view — create with `cx.new(|cx| Menubar::new(cx))` and add menus
16/// via [`Menubar::item`].
17#[derive(RegisterComponent)]
18pub struct Menubar {
19    items: Vec<MenubarItem>,
20    open_index: Option<usize>,
21    focus_index: usize,
22}
23
24impl Menubar {
25    pub fn new(_cx: &mut Context<Self>) -> Self {
26        Self {
27            items: Vec::new(),
28            open_index: None,
29            focus_index: 0,
30        }
31    }
32
33    pub fn item(mut self, label: impl Into<SharedString>, menu: Entity<ContextMenu>) -> Self {
34        self.items.push(MenubarItem {
35            label: label.into(),
36            menu,
37            handle: PopoverMenuHandle::default(),
38        });
39        self
40    }
41
42    fn close_all(&mut self, cx: &mut App) {
43        for item in &self.items {
44            item.handle.hide(cx);
45        }
46        self.open_index = None;
47    }
48
49    fn open_at(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
50        if index >= self.items.len() {
51            return;
52        }
53        self.close_all(cx);
54        self.focus_index = index;
55        self.open_index = Some(index);
56        self.items[index].handle.show(window, cx);
57        cx.notify();
58    }
59
60    fn move_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
61        if self.items.is_empty() {
62            return;
63        }
64        let len = self.items.len() as isize;
65        let next = (self.focus_index as isize + delta).rem_euclid(len) as usize;
66        if self.open_index.is_some() {
67            self.open_at(next, window, cx);
68        } else {
69            self.focus_index = next;
70            cx.notify();
71        }
72    }
73}
74
75impl Render for Menubar {
76    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
77        let mut bar = h_flex()
78            .id("menubar")
79            .w_full()
80            .items_center()
81            .gap_1()
82            .px_3()
83            .py_1()
84            .bg(semantic::surface(cx))
85            .border_b_1()
86            .border_color(semantic::border(cx))
87            .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
88                this.move_focus(-1, window, cx);
89            }))
90            .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
91                this.move_focus(1, window, cx);
92            }));
93
94        for (i, item) in self.items.iter().enumerate() {
95            let label = item.label.clone();
96            let menu = item.menu.clone();
97            let handle = item.handle.clone();
98            let is_open = self.open_index == Some(i);
99            let is_focused = self.focus_index == i;
100
101            bar = bar.child(
102                PopoverMenu::new(("menubar", i))
103                    .attach(Anchor::BottomLeft)
104                    .with_handle(handle)
105                    .menu(move |_, _| Some(menu.clone()))
106                    .trigger(
107                        Button::new(("menubar-trigger", i), label)
108                            .style(ButtonStyle::Transparent)
109                            .toggle_state(is_open)
110                            .when(is_focused, |this| {
111                                this.color(Color::Custom(palette::primary(600)))
112                            }),
113                    ),
114            );
115        }
116
117        bar
118    }
119}
120
121impl Component for Menubar {
122    fn scope() -> ComponentScope {
123        ComponentScope::Navigation
124    }
125
126    fn description() -> Option<&'static str> {
127        Some("A horizontal menubar with dropdown menus and cross-menu keyboard navigation.")
128    }
129
130    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
131        let file_menu = ContextMenu::build(window, cx, |this, _, _| {
132            this.entry("New Tab", None, |_, _| {})
133                .entry("New Window", None, |_, _| {})
134                .separator()
135                .entry("Close Tab", None, |_, _| {})
136        });
137        let edit_menu = ContextMenu::build(window, cx, |this, _, _| {
138            this.entry("Undo", None, |_, _| {})
139                .entry("Redo", None, |_, _| {})
140                .separator()
141                .entry("Cut", None, |_, _| {})
142                .entry("Copy", None, |_, _| {})
143                .entry("Paste", None, |_, _| {})
144        });
145        let view_menu = ContextMenu::build(window, cx, |this, _, _| {
146            this.entry("Sidebar", None, |_, _| {})
147                .entry("Panel", None, |_, _| {})
148        });
149
150        Some(
151            cx.new(|cx| {
152                Menubar::new(cx)
153                    .item("File", file_menu)
154                    .item("Edit", edit_menu)
155                    .item("View", view_menu)
156            })
157            .into_any_element(),
158        )
159    }
160}