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_keystroke("⌘N"),
27//! Item::submenu("Open Recent", vec![Item::action("bezel.md")]),
28//! Item::Separator,
29//! Item::action("Close").with_keystroke("⌘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 bar's bindings. Call once, alongside [`crate::input::init`].
85///
86/// `left`/`right` cross between menus and `up`/`down` walk the rows, which is
87/// the one arrangement every platform's menubar agrees on. With a submenu in
88/// reach they open and close it first, and only cross once there is no level
89/// left to move through. Nothing claims `alt` to focus the bar: that is a
90/// Windows convention, and a component library that binds a chord it is unsure
91/// of takes it away from every app downstream.
92pub fn init(cx: &mut App) {
93 let ctx = Some(KEY_CONTEXT);
94 cx.bind_keys([
95 KeyBinding::new("left", PrevMenu, ctx),
96 KeyBinding::new("right", NextMenu, ctx),
97 KeyBinding::new("up", PrevItem, ctx),
98 KeyBinding::new("down", NextItem, ctx),
99 KeyBinding::new("enter", Confirm, ctx),
100 KeyBinding::new("escape", Dismiss, ctx),
101 ]);
102}
103
104/// What the bar reports: an item chosen, by its place in the menus it was given.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub enum MenubarEvent {
107 /// `path` is a row index per level, outermost first — one entry for a
108 /// top-level row, two for a row in a submenu. [`Menu::at`] turns it back
109 /// into the item.
110 Selected { menu: usize, path: Vec<usize> },
111}
112
113pub struct Menubar {
114 menus: Vec<Menu>,
115 /// Which title is down. One popup for the whole bar rather than one each:
116 /// exactly one menu can be open, and saying so in the type is what makes
117 /// switching between them a single assignment.
118 open: popover::Popup<usize>,
119 /// Where the keyboard and the pointer both are inside the open menu, and
120 /// which of its submenus are down. Cleared whenever the menu changes, so a
121 /// fresh menu opens with nothing highlighted rather than with the last
122 /// one's row number pointing at whatever now sits there.
123 cursor: menu::Cursor,
124 focus_handle: FocusHandle,
125}
126
127impl EventEmitter<MenubarEvent> for Menubar {}
128
129impl Menubar {
130 pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
131 Self {
132 menus,
133 open: popover::Popup::default(),
134 cursor: menu::Cursor::default(),
135 // One stop for the whole bar: the menus are keyboard-driven from
136 // here, so no row takes focus of its own.
137 focus_handle: cx.focus_handle().tab_stop(true),
138 }
139 }
140
141 /// Which menu is down, `None` while none is (or while one is closing).
142 pub fn open_menu(&self) -> Option<usize> {
143 self.open.as_open().copied()
144 }
145
146 /// Where the pointer and the keyboard are in the open menu — which
147 /// submenus are down, and which row is live.
148 pub fn cursor(&self) -> &menu::Cursor {
149 &self.cursor
150 }
151
152 /// The menus as given. [`MenubarEvent`] reports a place in this list, so
153 /// this is how a host turns one back into the item it named — without
154 /// keeping a second copy that could drift from the bar's.
155 pub fn menus(&self) -> &[Menu] {
156 &self.menus
157 }
158
159 fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
160 self.open.open(menu);
161 self.cursor.clear();
162 window.focus(&self.focus_handle, cx);
163 cx.notify();
164 }
165
166 fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
167 // The note was taken on mouse-down and only counts for *this* title, so
168 // pressing a different one switches menus instead of being swallowed by
169 // the dismissal that same press caused.
170 if self.open.take_press_was_open() {
171 self.close(cx);
172 } else {
173 self.show(menu, window, cx);
174 }
175 }
176
177 /// The rule that makes a bar a bar: with one menu already down, the pointer
178 /// crossing a sibling title opens it. With none down, hovering does nothing
179 /// — a menubar that dropped a menu at the mere passage of the mouse would be
180 /// unusable.
181 fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
182 if self.open.is_open() && self.open_menu() != Some(menu) {
183 self.open.open(menu);
184 self.cursor.clear();
185 cx.notify();
186 }
187 }
188
189 fn close(&mut self, cx: &mut Context<Self>) {
190 if self.open.begin_close() {
191 popover::reap_popup(cx, |bar: &mut Self| &mut bar.open);
192 }
193 // Before the exit plays, not after: a submenu paints on a layer of its
194 // own and would hang there, unfaded, over the menu dissolving under it.
195 self.cursor.clear();
196 cx.notify();
197 }
198
199 fn choose(&mut self, menu: usize, path: Vec<usize>, cx: &mut Context<Self>) {
200 cx.emit(MenubarEvent::Selected { menu, path });
201 self.close(cx);
202 }
203
204 fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
205 let Some(menu) = self.open_menu() else { return };
206 self.cursor.step(&self.menus[menu].items, delta);
207 cx.notify();
208 }
209
210 fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
211 let Some(menu) = self.open_menu() else { return };
212 let count = self.menus.len() as isize;
213 if count == 0 {
214 return;
215 }
216 self.open
217 .open((menu as isize + delta).rem_euclid(count) as usize);
218 self.cursor.clear();
219 cx.notify();
220 }
221
222 /// `right`: into the submenu under the cursor if there is one, else across
223 /// to the next menu. A submenu row that swallowed `right` without opening
224 /// would be a dead key on the one row that has somewhere to go.
225 fn go_deeper(&mut self, cx: &mut Context<Self>) {
226 let Some(menu) = self.open_menu() else { return };
227 if self.cursor.descend(&self.menus[menu].items) {
228 cx.notify();
229 } else {
230 self.step_menu(1, cx);
231 }
232 }
233
234 /// `left`: out of the innermost submenu, else back to the previous menu.
235 fn go_shallower(&mut self, cx: &mut Context<Self>) {
236 if self.cursor.ascend() {
237 cx.notify();
238 } else {
239 self.step_menu(-1, cx);
240 }
241 }
242
243 /// What the pointer did to the open menu. Only a cursor that actually moved
244 /// is worth a frame — `on_mouse_move` reports every pixel.
245 fn hit(&mut self, hit: menu::Hit, cx: &mut Context<Self>) {
246 let Some(menu) = self.open_menu() else { return };
247 match hit {
248 menu::Hit::Point(path) => {
249 if self.cursor.point_at(&self.menus[menu].items, &path) {
250 cx.notify();
251 }
252 }
253 menu::Hit::Choose(path) => self.choose(menu, path, cx),
254 menu::Hit::Dismiss => self.close(cx),
255 }
256 }
257
258 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
259 match (self.open_menu(), self.cursor.path()) {
260 // A submenu row's `enter` opens it, the way `right` does; only an
261 // action row is a choice.
262 (Some(menu), Some(path)) => {
263 if self.cursor.descend(&self.menus[menu].items) {
264 cx.notify();
265 } else {
266 self.choose(menu, path, cx);
267 }
268 }
269 // Closed, `enter` drops the first menu — the same key means "act on
270 // this control" either way, which is what makes the bar reachable
271 // by keyboard at all.
272 (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
273 _ => {}
274 }
275 }
276
277 /// `escape` closes one level at a time, the bar itself last.
278 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
279 if self.cursor.ascend() {
280 cx.notify();
281 } else {
282 self.close(cx);
283 }
284 }
285
286 fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
287 menu::card(
288 theme,
289 SharedString::from(format!("menu-{menu}")),
290 &self.menus[menu].items,
291 &self.cursor,
292 cx,
293 |bar, hit, _, cx| bar.hit(hit, cx),
294 )
295 .into_any_element()
296 }
297}
298
299/// One title on the strip. Lit while its own menu is down.
300pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
301 let title = div()
302 .px(px(8.0))
303 .py(px(3.0))
304 .rounded(px(Theme::control_radius()))
305 .text_style(TextStyle::Body)
306 .cursor_pointer()
307 .child(label.into());
308 if open {
309 title.bg(theme.element_active).text_color(theme.text)
310 } else {
311 // A plain hover style, not a `motion::hover_blend` fade key: the fade
312 // installs an `on_hover` *listener*, and gpui allows only one per
313 // element — the switch below needs it.
314 title
315 .text_color(theme.text_muted)
316 .hover(|s| s.bg(theme.element_hover).text_color(theme.text))
317 }
318}
319
320/// The strip the titles sit on.
321pub fn menubar() -> gpui::Div {
322 div().flex().flex_row().items_center().gap(px(2.0))
323}
324
325impl Focusable for Menubar {
326 fn focus_handle(&self, _: &App) -> FocusHandle {
327 self.focus_handle.clone()
328 }
329}
330
331impl Render for Menubar {
332 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
333 let theme = Theme::of(cx).clone();
334 // `get`, not `as_open`: the card stays mounted through the exit phase.
335 let mounted = self.open.get().copied();
336 let closing = self.open.closing_since();
337
338 menubar()
339 .key_context(KEY_CONTEXT)
340 .track_focus(&self.focus_handle)
341 .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.go_shallower(cx)))
342 .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.go_deeper(cx)))
343 .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
344 .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
345 .on_action(cx.listener(Self::confirm))
346 .on_action(cx.listener(Self::dismiss))
347 .children((0..self.menus.len()).map(|menu| {
348 let down = mounted == Some(menu);
349 let card = down.then(|| self.card(menu, &theme, cx));
350 div()
351 .relative()
352 .id(SharedString::from(format!("menubar-title-{menu}")))
353 .on_mouse_down(
354 gpui::MouseButton::Left,
355 cx.listener(move |bar, _, _, _| {
356 bar.open.note_trigger_press_matching(|open| *open == menu)
357 }),
358 )
359 .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
360 .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
361 if *hovered {
362 bar.hover_switch(menu, cx);
363 }
364 }))
365 .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
366 .when_some(card, |title, card| {
367 title.child(popover::anchored_menu_below(
368 SharedString::from(format!("menubar-menu-{menu}")),
369 card,
370 closing,
371 ))
372 })
373 }))
374 }
375}