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