Skip to main content

guise/overlay/
menubar.rs

1//! `MenuBar` — a horizontal application menu (File / Edit / View / …).
2//!
3//! Each top-level label opens a dropdown of items. Once any menu is open,
4//! moving the pointer onto a sibling label switches to it — the classic
5//! desktop menu-bar feel. Keyboard: left/right switch menus, up/down move the
6//! highlight within the open menu, enter activates, escape closes.
7//!
8//! Built as a gpui entity, like [`Menu`](super::Menu); drop it into a titlebar
9//! strip or a [`StatusBar`](crate::nav::StatusBar) slot:
10//!
11//! ```ignore
12//! cx.new(|cx| {
13//!     MenuBar::new(cx)
14//!         .menu("File", |m| {
15//!             m.item_shortcut("New Tab", "⌘T", |_, cx| { /* … */ })
16//!                 .item("New Window", |_, cx| { /* … */ })
17//!                 .divider()
18//!                 .danger_item("Quit", |_, cx| { /* … */ })
19//!         })
20//!         .menu("Edit", |m| {
21//!             m.item_shortcut("Copy", "⌘C", |_, cx| {})
22//!                 .item_shortcut("Paste", "⌘V", |_, cx| {})
23//!                 .disabled_item("Redo")
24//!         })
25//! })
26//! ```
27
28use gpui::prelude::*;
29use gpui::{
30    deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
31};
32
33use crate::input::control_metrics;
34use crate::theme::{theme, ColorName, Size};
35
36type ItemHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
37
38enum Entry {
39    Item {
40        label: SharedString,
41        shortcut: Option<SharedString>,
42        danger: bool,
43        disabled: bool,
44        handler: Option<ItemHandler>,
45    },
46    Section(SharedString),
47    Divider,
48}
49
50/// One top-level menu in a [`MenuBar`]: a label plus its dropdown entries.
51///
52/// You rarely name this type directly — [`MenuBar::menu`] hands you one to
53/// build inside a closure. It is exported so menus can also be assembled
54/// programmatically and pushed with [`MenuBar::push`].
55pub struct MenuColumn {
56    label: SharedString,
57    entries: Vec<Entry>,
58}
59
60impl MenuColumn {
61    /// Start an empty menu with the given top-level label.
62    pub fn new(label: impl Into<SharedString>) -> Self {
63        MenuColumn {
64            label: label.into(),
65            entries: Vec::new(),
66        }
67    }
68
69    /// Add an action item.
70    pub fn item(
71        self,
72        label: impl Into<SharedString>,
73        handler: impl Fn(&mut Window, &mut App) + 'static,
74    ) -> Self {
75        self.entry(label, None, false, false, Some(Box::new(handler)))
76    }
77
78    /// Add an action item with a right-aligned shortcut hint (e.g. `"⌘T"`).
79    pub fn item_shortcut(
80        self,
81        label: impl Into<SharedString>,
82        shortcut: impl Into<SharedString>,
83        handler: impl Fn(&mut Window, &mut App) + 'static,
84    ) -> Self {
85        self.entry(
86            label,
87            Some(shortcut.into()),
88            false,
89            false,
90            Some(Box::new(handler)),
91        )
92    }
93
94    /// Add a destructive action item, rendered in red.
95    pub fn danger_item(
96        self,
97        label: impl Into<SharedString>,
98        handler: impl Fn(&mut Window, &mut App) + 'static,
99    ) -> Self {
100        self.entry(label, None, true, false, Some(Box::new(handler)))
101    }
102
103    /// Add a disabled item: greyed out, no shortcut, not clickable or
104    /// keyboard-selectable.
105    pub fn disabled_item(self, label: impl Into<SharedString>) -> Self {
106        self.entry(label, None, false, true, None)
107    }
108
109    /// Add a non-interactive section label.
110    pub fn section(mut self, label: impl Into<SharedString>) -> Self {
111        self.entries.push(Entry::Section(label.into()));
112        self
113    }
114
115    /// Add a separating divider.
116    pub fn divider(mut self) -> Self {
117        self.entries.push(Entry::Divider);
118        self
119    }
120
121    fn entry(
122        mut self,
123        label: impl Into<SharedString>,
124        shortcut: Option<SharedString>,
125        danger: bool,
126        disabled: bool,
127        handler: Option<ItemHandler>,
128    ) -> Self {
129        self.entries.push(Entry::Item {
130            label: label.into(),
131            shortcut,
132            danger,
133            disabled,
134            handler,
135        });
136        self
137    }
138
139    /// Entry indices that are actionable (enabled items with a handler).
140    fn actionable(&self) -> Vec<usize> {
141        self.entries
142            .iter()
143            .enumerate()
144            .filter(|(_, e)| {
145                matches!(
146                    e,
147                    Entry::Item {
148                        disabled: false,
149                        handler: Some(_),
150                        ..
151                    }
152                )
153            })
154            .map(|(i, _)| i)
155            .collect()
156    }
157}
158
159/// A horizontal strip of dropdown menus — an application menu bar.
160///
161/// Create with `cx.new(|cx| MenuBar::new(cx))`, then add menus with
162/// [`menu`](Self::menu).
163pub struct MenuBar {
164    menus: Vec<MenuColumn>,
165    /// Index of the open top-level menu, if any.
166    open: Option<usize>,
167    focus: FocusHandle,
168    size: Size,
169    /// Entry index of the keyboard-highlighted item within the open menu.
170    highlight: usize,
171}
172
173impl MenuBar {
174    pub fn new(cx: &mut Context<Self>) -> Self {
175        MenuBar {
176            menus: Vec::new(),
177            open: None,
178            focus: cx.focus_handle(),
179            size: Size::Sm,
180            highlight: 0,
181        }
182    }
183
184    /// Sizing token for the top-level labels.
185    pub fn size(mut self, size: Size) -> Self {
186        self.size = size;
187        self
188    }
189
190    /// Add a top-level menu, building its entries in the closure.
191    pub fn menu(
192        mut self,
193        label: impl Into<SharedString>,
194        build: impl FnOnce(MenuColumn) -> MenuColumn,
195    ) -> Self {
196        self.menus.push(build(MenuColumn::new(label)));
197        self
198    }
199
200    /// Add a pre-built [`MenuColumn`] (for menus assembled programmatically).
201    pub fn push(mut self, menu: MenuColumn) -> Self {
202        self.menus.push(menu);
203        self
204    }
205
206    /// Open a menu and highlight its first actionable item.
207    fn open_menu(&mut self, idx: usize) {
208        self.open = Some(idx);
209        self.highlight = self
210            .menus
211            .get(idx)
212            .and_then(|m| m.actionable().first().copied())
213            .unwrap_or(0);
214    }
215
216    fn move_menu(&mut self, delta: isize) {
217        if self.menus.is_empty() {
218            return;
219        }
220        let cur = self.open.unwrap_or(0) as isize;
221        let len = self.menus.len() as isize;
222        let next = (((cur + delta) % len) + len) % len;
223        self.open_menu(next as usize);
224    }
225
226    fn move_highlight(&mut self, delta: isize) {
227        let Some(open) = self.open else { return };
228        let Some(menu) = self.menus.get(open) else {
229            return;
230        };
231        let items = menu.actionable();
232        if items.is_empty() {
233            return;
234        }
235        let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
236        let len = items.len() as isize;
237        let next = (((pos as isize + delta) % len) + len) % len;
238        self.highlight = items[next as usize];
239    }
240
241    fn activate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
242        let Some(open) = self.open else { return };
243        self.open = None;
244        if let Some(Entry::Item {
245            handler: Some(handler),
246            ..
247        }) = self
248            .menus
249            .get(open)
250            .and_then(|m| m.entries.get(self.highlight))
251        {
252            handler(window, cx);
253        }
254    }
255
256    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
257        if self.open.is_none() {
258            return;
259        }
260        match event.keystroke.key.as_str() {
261            "escape" => self.open = None,
262            "left" => self.move_menu(-1),
263            "right" => self.move_menu(1),
264            "down" => self.move_highlight(1),
265            "up" => self.move_highlight(-1),
266            "enter" => self.activate(window, cx),
267            _ => return,
268        }
269        cx.notify();
270        cx.stop_propagation();
271    }
272}
273
274impl Render for MenuBar {
275    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
276        let t = theme(cx);
277        let (height, pad_x, font) = control_metrics(self.size);
278        let radius = t.radius(t.default_radius);
279        let surface_color = t.surface().hsla();
280        let surface_hover = t.surface_hover().hsla();
281        let border = t.border().hsla();
282        let text = t.text().hsla();
283        let dimmed = t.dimmed().hsla();
284        let danger = t
285            .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
286            .hsla();
287        let font_xs = t.font_size(Size::Xs);
288
289        let mut bar = div()
290            .id("guise-menubar")
291            .track_focus(&self.focus)
292            .flex()
293            .flex_row()
294            .items_center()
295            .gap(px(2.0))
296            .text_size(px(font))
297            .on_key_down(cx.listener(Self::on_key));
298
299        for (mi, menu) in self.menus.iter().enumerate() {
300            let is_open = self.open == Some(mi);
301
302            let mut label = div()
303                .id(("guise-menubar-label", mi))
304                .flex()
305                .items_center()
306                .h(px(height))
307                .px(px(pad_x))
308                .rounded(px(radius))
309                .text_color(text)
310                .hover(move |s| s.bg(surface_hover))
311                .child(menu.label.clone())
312                .on_click(cx.listener(move |this, _ev, window, cx| {
313                    if this.open == Some(mi) {
314                        this.open = None;
315                    } else {
316                        this.open_menu(mi);
317                        window.focus(&this.focus);
318                    }
319                    cx.notify();
320                }))
321                // Once a menu is open, hovering a sibling label switches to it.
322                .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
323                    if *hovered && this.open.is_some() && this.open != Some(mi) {
324                        this.open_menu(mi);
325                        cx.notify();
326                    }
327                }));
328            if is_open {
329                label = label.bg(surface_hover);
330            }
331
332            let mut wrap = div().relative().child(label);
333
334            if is_open {
335                let mut dropdown = div()
336                    .absolute()
337                    .top(px(height + 4.0))
338                    .left(px(0.0))
339                    .min_w(px(200.0))
340                    .flex()
341                    .flex_col()
342                    .gap(px(2.0))
343                    .p(px(4.0))
344                    .rounded(px(radius))
345                    .border_1()
346                    .border_color(border)
347                    .bg(surface_color)
348                    .shadow_md();
349
350                for (ei, entry) in menu.entries.iter().enumerate() {
351                    match entry {
352                        Entry::Item {
353                            label,
354                            shortcut,
355                            danger: is_danger,
356                            disabled,
357                            ..
358                        } => {
359                            let color = if *disabled {
360                                dimmed
361                            } else if *is_danger {
362                                danger
363                            } else {
364                                text
365                            };
366                            let mut item = div()
367                                .id(("guise-menubar-item", mi * 1000 + ei))
368                                .flex()
369                                .items_center()
370                                .justify_between()
371                                .gap(px(24.0))
372                                .px(px(10.0))
373                                .py(px(6.0))
374                                .rounded(px(4.0))
375                                .text_size(px(font))
376                                .text_color(color)
377                                .child(label.clone())
378                                .child(match shortcut {
379                                    Some(s) => div()
380                                        .text_size(px(font_xs))
381                                        .text_color(dimmed)
382                                        .child(s.clone()),
383                                    None => div(),
384                                });
385                            if !*disabled {
386                                item = item.hover(move |s| s.bg(surface_hover));
387                                if ei == self.highlight {
388                                    item = item.bg(surface_hover);
389                                }
390                                item = item.on_click(cx.listener(move |this, _ev, window, cx| {
391                                    this.open = None;
392                                    if let Some(Entry::Item {
393                                        handler: Some(handler),
394                                        ..
395                                    }) = this.menus.get(mi).and_then(|m| m.entries.get(ei))
396                                    {
397                                        handler(window, cx);
398                                    }
399                                    cx.notify();
400                                }));
401                            }
402                            dropdown = dropdown.child(item);
403                        }
404                        Entry::Section(label) => {
405                            dropdown = dropdown.child(
406                                div()
407                                    .px(px(10.0))
408                                    .pt(px(6.0))
409                                    .pb(px(2.0))
410                                    .text_size(px(font_xs))
411                                    .text_color(dimmed)
412                                    .child(label.clone()),
413                            );
414                        }
415                        Entry::Divider => {
416                            dropdown = dropdown.child(div().my(px(4.0)).h(px(1.0)).bg(border));
417                        }
418                    }
419                }
420
421                wrap = wrap.child(deferred(dropdown).with_priority(1));
422            }
423
424            bar = bar.child(wrap);
425        }
426
427        bar
428    }
429}