ui/menubar.rs
1//! [`Menubar`] — the in-window bar: a strip of titles that drop menus.
2//!
3//! Not the *native* one. On macOS that is `cx.set_menus` and four lines in an
4//! app's `main`, which is where it belongs; this is the bar an app with a custom
5//! titlebar draws for itself, and the one every other platform expects to see
6//! inside the window.
7//!
8//! An entity, on the line [`crate::date::Calendar`] drew: it owns which menu is
9//! down and where the keyboard is inside it — state the app has no opinion
10//! about — and reports the one thing the app wants, through [`MenubarEvent`].
11//! The menus are data the app hands over, shaped like gpui's own `Menu` and
12//! `MenuItem` so an app drawing both bars writes them the same way. It does not
13//! *take* those types: they carry a boxed action, and reporting an index leaves
14//! dispatch with the app, the way [`crate::combobox`] and [`crate::palette`]
15//! already do.
16//!
17//! What makes it a menubar rather than a row of dropdowns is that one menu being
18//! open changes what the others do: sliding the pointer onto a sibling title
19//! switches to it with no click, and `left`/`right` cross between menus without
20//! leaving the keyboard.
21//!
22//! ```ignore
23//! ui::menubar::init(cx); // once, at startup
24//! let bar = cx.new(|cx| Menubar::new(vec![
25//! Menu::new("File", vec![
26//! Item::action("New Window").with_shortcut(&NewWindow, window),
27//! Item::submenu("Open Recent", vec![Item::action("bezel.md")]),
28//! Item::Separator,
29//! Item::action("Close").with_keystroke(keys::printed("secondary-w")).disabled(),
30//! ]),
31//! ], cx));
32//! cx.subscribe(&bar, |_, bar, event, cx| match event {
33//! MenubarEvent::Selected { menu, path } => { /* dispatch */ }
34//! })
35//! .detach();
36//! ```
37
38use gpui::{
39 App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
40 div, prelude::*, px,
41};
42
43use theme::{TextStyle, Theme, Typeset};
44
45use crate::{
46 menu::{self, Item},
47 popover,
48};
49
50/// One menu on the bar.
51#[derive(Clone, Debug)]
52pub struct Menu {
53 pub title: SharedString,
54 pub items: Vec<Item>,
55}
56
57impl Menu {
58 pub fn new(title: impl Into<SharedString>, items: Vec<Item>) -> Self {
59 Self {
60 title: title.into(),
61 items,
62 }
63 }
64
65 /// The item a [`MenubarEvent::Selected`] path names, submenus walked.
66 pub fn at(&self, path: &[usize]) -> Option<&Item> {
67 menu::at(&self.items, path)
68 }
69}
70
71// ---------------------------------------------------------------------------
72// The bar
73// ---------------------------------------------------------------------------
74
75actions!(
76 bezel_menubar,
77 [PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
78);
79
80/// The key context the bar claims, closed as well as open — `enter` on a
81/// focused-but-closed bar drops its first menu.
82pub const KEY_CONTEXT: &str = "Menubar";
83
84/// Install the bindings — [`bindings`], bound. Call once at startup.
85pub fn init(cx: &mut App) {
86 cx.bind_keys(bindings());
87}
88
89/// The bar's keymap, as data, so an app can have it without having to
90/// take it — see [`crate::keys`] for layering over it or taking a chord
91/// away.
92///
93/// `left`/`right` cross between menus and `up`/`down` walk the rows, which is
94/// the one arrangement every platform's menubar agrees on. With a submenu in
95/// reach they open and close it first, and only cross once there is no level
96/// left to move through. Nothing claims `alt` to focus the bar: that is a
97/// Windows convention, and a component library that binds a chord it is unsure
98/// of takes it away from every app downstream.
99pub fn bindings() -> Vec<KeyBinding> {
100 let mut bindings = Vec::new();
101 let ctx = Some(KEY_CONTEXT);
102 bindings.extend([
103 KeyBinding::new("left", PrevMenu, ctx),
104 KeyBinding::new("right", NextMenu, ctx),
105 KeyBinding::new("up", PrevItem, ctx),
106 KeyBinding::new("down", NextItem, ctx),
107 KeyBinding::new("enter", Confirm, ctx),
108 KeyBinding::new("escape", Dismiss, ctx),
109 ]);
110
111 bindings
112}
113
114/// What the bar reports: an item chosen, by its place in the menus it was given.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum MenubarEvent {
117 /// `path` is a row index per level, outermost first — one entry for a
118 /// top-level row, two for a row in a submenu — and then the segment's
119 /// index for a pick in a segmented row. [`Menu::at`] turns it back into
120 /// the item.
121 Selected { menu: usize, path: Vec<usize> },
122}
123
124pub struct Menubar {
125 menus: Vec<Menu>,
126 /// Which title is down. One popup for the whole bar rather than one each:
127 /// exactly one menu can be open, and saying so in the type is what makes
128 /// switching between them a single assignment.
129 open: popover::Popup<usize>,
130 /// Where the keyboard and the pointer both are inside the open menu, and
131 /// which of its submenus are down. Cleared whenever the menu changes, so a
132 /// fresh menu opens with nothing highlighted rather than with the last
133 /// one's row number pointing at whatever now sits there.
134 cursor: menu::Cursor,
135 focus_handle: FocusHandle,
136}
137
138impl EventEmitter<MenubarEvent> for Menubar {}
139
140impl Menubar {
141 pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
142 Self {
143 menus,
144 open: popover::Popup::default(),
145 cursor: menu::Cursor::default(),
146 // One stop for the whole bar: the menus are keyboard-driven from
147 // here, so no row takes focus of its own.
148 focus_handle: cx.focus_handle().tab_stop(true),
149 }
150 }
151
152 /// Which menu is down, `None` while none is (or while one is closing).
153 pub fn open_menu(&self) -> Option<usize> {
154 self.open.as_open().copied()
155 }
156
157 /// Where the pointer and the keyboard are in the open menu — which
158 /// submenus are down, and which row is live.
159 pub fn cursor(&self) -> &menu::Cursor {
160 &self.cursor
161 }
162
163 /// The menus as given. [`MenubarEvent`] reports a place in this list, so
164 /// this is how a host turns one back into the item it named — without
165 /// keeping a second copy that could drift from the bar's.
166 pub fn menus(&self) -> &[Menu] {
167 &self.menus
168 }
169
170 fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
171 self.open.open(menu);
172 self.cursor.clear();
173 window.focus(&self.focus_handle, cx);
174 cx.notify();
175 }
176
177 fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
178 // The note was taken on mouse-down and only counts for *this* title, so
179 // pressing a different one switches menus instead of being swallowed by
180 // the dismissal that same press caused.
181 if self.open.take_press_was_open() {
182 self.close(cx);
183 } else {
184 self.show(menu, window, cx);
185 }
186 }
187
188 /// The rule that makes a bar a bar: with one menu already down, the pointer
189 /// crossing a sibling title opens it. With none down, hovering does nothing
190 /// — a menubar that dropped a menu at the mere passage of the mouse would be
191 /// unusable.
192 fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
193 if self.open.is_open() && self.open_menu() != Some(menu) {
194 self.open.open(menu);
195 self.cursor.clear();
196 cx.notify();
197 }
198 }
199
200 fn close(&mut self, cx: &mut Context<Self>) {
201 if self.open.begin_close() {
202 popover::reap_popup(self, cx, |bar: &mut Self| &mut bar.open);
203 }
204 // Before the exit plays, not after: a submenu paints on a layer of its
205 // own and would hang there, unfaded, over the menu dissolving under it.
206 self.cursor.clear();
207 cx.notify();
208 }
209
210 fn choose(&mut self, menu: usize, path: Vec<usize>, cx: &mut Context<Self>) {
211 cx.emit(MenubarEvent::Selected { menu, path });
212 self.close(cx);
213 }
214
215 fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
216 let Some(menu) = self.open_menu() else { return };
217 self.cursor.step(&self.menus[menu].items, delta);
218 cx.notify();
219 }
220
221 fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
222 let Some(menu) = self.open_menu() else { return };
223 let count = self.menus.len() as isize;
224 if count == 0 {
225 return;
226 }
227 self.open
228 .open((menu as isize + delta).rem_euclid(count) as usize);
229 self.cursor.clear();
230 cx.notify();
231 }
232
233 /// `right`: to the next segment of a segmented row, else into the submenu
234 /// under the cursor if there is one, else across to the next menu. A
235 /// submenu row that swallowed `right` without opening would be a dead key
236 /// on the one row that has somewhere to go.
237 fn go_deeper(&mut self, cx: &mut Context<Self>) {
238 let Some(menu) = self.open_menu() else { return };
239 let items = &self.menus[menu].items;
240 if self.cursor.slide(items, 1) || self.cursor.descend(items) {
241 cx.notify();
242 } else {
243 self.step_menu(1, cx);
244 }
245 }
246
247 /// `left`: to the previous segment of a segmented row, else out of the
248 /// innermost submenu, else back to the previous menu.
249 fn go_shallower(&mut self, cx: &mut Context<Self>) {
250 let slid = self
251 .open_menu()
252 .is_some_and(|menu| self.cursor.slide(&self.menus[menu].items, -1));
253 if slid || self.cursor.ascend() {
254 cx.notify();
255 } else {
256 self.step_menu(-1, cx);
257 }
258 }
259
260 /// What the pointer did to the open menu. Only a cursor that actually moved
261 /// is worth a frame — `on_mouse_move` reports every pixel.
262 fn hit(&mut self, hit: menu::Hit, cx: &mut Context<Self>) {
263 let Some(menu) = self.open_menu() else { return };
264 match hit {
265 menu::Hit::Point(path) => {
266 if self.cursor.point_at(&self.menus[menu].items, &path) {
267 cx.notify();
268 }
269 }
270 menu::Hit::Choose(path) => self.choose(menu, path, cx),
271 menu::Hit::Dismiss => self.close(cx),
272 }
273 }
274
275 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
276 match (self.open_menu(), self.cursor.path()) {
277 // A submenu row's `enter` opens it, the way `right` does; only an
278 // action row is a choice.
279 (Some(menu), Some(path)) => {
280 if self.cursor.descend(&self.menus[menu].items) {
281 cx.notify();
282 } else {
283 self.choose(menu, path, cx);
284 }
285 }
286 // Closed, `enter` drops the first menu — the same key means "act on
287 // this control" either way, which is what makes the bar reachable
288 // by keyboard at all.
289 (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
290 _ => {}
291 }
292 }
293
294 /// `escape` closes one level at a time, the bar itself last.
295 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
296 if self.cursor.ascend() {
297 cx.notify();
298 } else {
299 self.close(cx);
300 }
301 }
302
303 fn card(
304 &self,
305 menu: usize,
306 theme: &Theme,
307 window: &mut Window,
308 cx: &mut Context<Self>,
309 ) -> gpui::AnyElement {
310 menu::card(
311 theme,
312 SharedString::from(format!("menu-{menu}")),
313 &self.menus[menu].items,
314 &self.cursor,
315 self.open.closing_since(),
316 window,
317 cx,
318 |bar, hit, _, cx| bar.hit(hit, cx),
319 )
320 .into_any_element()
321 }
322}
323
324/// One title on the strip. Lit while its own menu is down.
325pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
326 let title = div()
327 .px(px(8.0))
328 .py(px(3.0))
329 .rounded(px(Theme::control_radius()))
330 .text_style(TextStyle::Body)
331 .cursor_pointer()
332 .child(label.into());
333 if open {
334 title.bg(theme.element_active).text_color(theme.text)
335 } else {
336 // A plain hover style, not a `motion::hover_blend` fade key: the fade
337 // installs an `on_hover` *listener*, and gpui allows only one per
338 // element — the switch below needs it.
339 title
340 .text_color(theme.text_muted)
341 .hover(|s| s.bg(theme.element_hover).text_color(theme.text))
342 }
343}
344
345/// The strip the titles sit on.
346pub fn menubar() -> gpui::Div {
347 div().flex().flex_row().items_center().gap(px(2.0))
348}
349
350impl Focusable for Menubar {
351 fn focus_handle(&self, _: &App) -> FocusHandle {
352 self.focus_handle.clone()
353 }
354}
355
356impl Render for Menubar {
357 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
358 let theme = Theme::of(cx).clone();
359 // `get`, not `as_open`: the card stays mounted through the exit phase.
360 let mounted = self.open.get().copied();
361 let closing = self.open.closing_since();
362
363 menubar()
364 .key_context(KEY_CONTEXT)
365 .track_focus(&self.focus_handle)
366 .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.go_shallower(cx)))
367 .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.go_deeper(cx)))
368 .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
369 .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
370 .on_action(cx.listener(Self::confirm))
371 .on_action(cx.listener(Self::dismiss))
372 .children((0..self.menus.len()).map(|menu| {
373 let down = mounted == Some(menu);
374 let card = down.then(|| self.card(menu, &theme, window, cx));
375 div()
376 .relative()
377 .id(SharedString::from(format!("menubar-title-{menu}")))
378 .on_mouse_down(
379 gpui::MouseButton::Left,
380 cx.listener(move |bar, _, _, _| {
381 bar.open.note_trigger_press_matching(|open| *open == menu)
382 }),
383 )
384 .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
385 .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
386 if *hovered {
387 bar.hover_switch(menu, cx);
388 }
389 }))
390 .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
391 .when_some(card, |title, card| {
392 title.child(popover::anchored_menu_below(
393 SharedString::from(format!("menubar-menu-{menu}")),
394 card,
395 closing,
396 ))
397 })
398 }))
399 }
400}