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
143      .entries
144      .iter()
145      .enumerate()
146      .filter(|(_, e)| {
147        matches!(
148          e,
149          Entry::Item {
150            disabled: false,
151            handler: Some(_),
152            ..
153          }
154        )
155      })
156      .map(|(i, _)| i)
157      .collect()
158  }
159}
160
161/// A horizontal strip of dropdown menus — an application menu bar.
162///
163/// Create with `cx.new(|cx| MenuBar::new(cx))`, then add menus with
164/// [`menu`](Self::menu).
165pub struct MenuBar {
166  menus: Vec<MenuColumn>,
167  /// Index of the open top-level menu, if any.
168  open: Option<usize>,
169  focus: FocusHandle,
170  size: Size,
171  /// Entry index of the keyboard-highlighted item within the open menu.
172  highlight: usize,
173}
174
175impl MenuBar {
176  pub fn new(cx: &mut Context<Self>) -> Self {
177    MenuBar {
178      menus: Vec::new(),
179      open: None,
180      focus: cx.focus_handle(),
181      size: Size::Sm,
182      highlight: 0,
183    }
184  }
185
186  /// Sizing token for the top-level labels.
187  pub fn size(mut self, size: Size) -> Self {
188    self.size = size;
189    self
190  }
191
192  /// Add a top-level menu, building its entries in the closure.
193  pub fn menu(
194    mut self,
195    label: impl Into<SharedString>,
196    build: impl FnOnce(MenuColumn) -> MenuColumn,
197  ) -> Self {
198    self.menus.push(build(MenuColumn::new(label)));
199    self
200  }
201
202  /// Add a pre-built [`MenuColumn`] (for menus assembled programmatically).
203  pub fn push(mut self, menu: MenuColumn) -> Self {
204    self.menus.push(menu);
205    self
206  }
207
208  /// Open a menu and highlight its first actionable item.
209  fn open_menu(&mut self, idx: usize) {
210    self.open = Some(idx);
211    self.highlight = self
212      .menus
213      .get(idx)
214      .and_then(|m| m.actionable().first().copied())
215      .unwrap_or(0);
216  }
217
218  fn move_menu(&mut self, delta: isize) {
219    if self.menus.is_empty() {
220      return;
221    }
222    let cur = self.open.unwrap_or(0) as isize;
223    let len = self.menus.len() as isize;
224    let next = (((cur + delta) % len) + len) % len;
225    self.open_menu(next as usize);
226  }
227
228  fn move_highlight(&mut self, delta: isize) {
229    let Some(open) = self.open else { return };
230    let Some(menu) = self.menus.get(open) else {
231      return;
232    };
233    let items = menu.actionable();
234    if items.is_empty() {
235      return;
236    }
237    let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
238    let len = items.len() as isize;
239    let next = (((pos as isize + delta) % len) + len) % len;
240    self.highlight = items[next as usize];
241  }
242
243  fn activate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
244    let Some(open) = self.open else { return };
245    self.open = None;
246    if let Some(Entry::Item {
247      handler: Some(handler),
248      ..
249    }) = self
250      .menus
251      .get(open)
252      .and_then(|m| m.entries.get(self.highlight))
253    {
254      handler(window, cx);
255    }
256  }
257
258  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
259    if self.open.is_none() {
260      return;
261    }
262    match event.keystroke.key.as_str() {
263      "escape" => self.open = None,
264      "left" => self.move_menu(-1),
265      "right" => self.move_menu(1),
266      "down" => self.move_highlight(1),
267      "up" => self.move_highlight(-1),
268      "enter" => self.activate(window, cx),
269      _ => return,
270    }
271    cx.notify();
272    cx.stop_propagation();
273  }
274}
275
276impl Render for MenuBar {
277  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
278    let t = theme(cx);
279    let (height, pad_x, font) = control_metrics(self.size);
280    let radius = t.radius(t.default_radius);
281    let surface_color = t.surface().hsla();
282    let surface_hover = t.surface_hover().hsla();
283    let border = t.border().hsla();
284    let text = t.text().hsla();
285    let dimmed = t.dimmed().hsla();
286    let danger = t
287      .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
288      .hsla();
289    let font_xs = t.font_size(Size::Xs);
290
291    let mut bar = div()
292      .id("guise-menubar")
293      .track_focus(&self.focus)
294      .flex()
295      .flex_row()
296      .items_center()
297      .gap(px(2.0))
298      .text_size(px(font))
299      .on_key_down(cx.listener(Self::on_key));
300
301    for (mi, menu) in self.menus.iter().enumerate() {
302      let is_open = self.open == Some(mi);
303
304      let mut label = div()
305        .id(("guise-menubar-label", mi))
306        .flex()
307        .items_center()
308        .h(px(height))
309        .px(px(pad_x))
310        .rounded(px(radius))
311        .text_color(text)
312        .hover(move |s| s.bg(surface_hover))
313        .child(menu.label.clone())
314        .on_click(cx.listener(move |this, _ev, window, cx| {
315          if this.open == Some(mi) {
316            this.open = None;
317          } else {
318            this.open_menu(mi);
319            window.focus(&this.focus);
320          }
321          cx.notify();
322        }))
323        // Once a menu is open, hovering a sibling label switches to it.
324        .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
325          if *hovered && this.open.is_some() && this.open != Some(mi) {
326            this.open_menu(mi);
327            cx.notify();
328          }
329        }));
330      if is_open {
331        label = label.bg(surface_hover);
332      }
333
334      let mut wrap = div().relative().child(label);
335
336      if is_open {
337        let mut dropdown = div()
338          .absolute()
339          .top(px(height + 4.0))
340          .left(px(0.0))
341          .min_w(px(200.0))
342          .flex()
343          .flex_col()
344          .gap(px(2.0))
345          .p(px(4.0))
346          .rounded(px(radius))
347          .border_1()
348          .border_color(border)
349          .bg(surface_color)
350          .shadow_md();
351
352        for (ei, entry) in menu.entries.iter().enumerate() {
353          match entry {
354            Entry::Item {
355              label,
356              shortcut,
357              danger: is_danger,
358              disabled,
359              ..
360            } => {
361              let color = if *disabled {
362                dimmed
363              } else if *is_danger {
364                danger
365              } else {
366                text
367              };
368              let mut item = div()
369                .id(("guise-menubar-item", mi * 1000 + ei))
370                .flex()
371                .items_center()
372                .justify_between()
373                .gap(px(24.0))
374                .px(px(10.0))
375                .py(px(6.0))
376                .rounded(px(4.0))
377                .text_size(px(font))
378                .text_color(color)
379                .child(label.clone())
380                .child(match shortcut {
381                  Some(s) => div()
382                    .text_size(px(font_xs))
383                    .text_color(dimmed)
384                    .child(s.clone()),
385                  None => div(),
386                });
387              if !*disabled {
388                item = item.hover(move |s| s.bg(surface_hover));
389                if ei == self.highlight {
390                  item = item.bg(surface_hover);
391                }
392                item = item.on_click(cx.listener(move |this, _ev, window, cx| {
393                  this.open = None;
394                  if let Some(Entry::Item {
395                    handler: Some(handler),
396                    ..
397                  }) = this.menus.get(mi).and_then(|m| m.entries.get(ei))
398                  {
399                    handler(window, cx);
400                  }
401                  cx.notify();
402                }));
403              }
404              dropdown = dropdown.child(item);
405            }
406            Entry::Section(label) => {
407              dropdown = dropdown.child(
408                div()
409                  .px(px(10.0))
410                  .pt(px(6.0))
411                  .pb(px(2.0))
412                  .text_size(px(font_xs))
413                  .text_color(dimmed)
414                  .child(label.clone()),
415              );
416            }
417            Entry::Divider => {
418              dropdown = dropdown.child(div().my(px(4.0)).h(px(1.0)).bg(border));
419            }
420          }
421        }
422
423        wrap = wrap.child(deferred(dropdown).with_priority(1));
424      }
425
426      bar = bar.child(wrap);
427    }
428
429    bar.probe("MenuBar")
430  }
431}