1use gpui::{
38 App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
39 div, prelude::*, px,
40};
41
42use motion::{Fade, Painter};
43use theme::{Theme, ink};
44
45use crate::popover;
46
47#[derive(Clone, Debug)]
49pub struct Menu {
50 pub title: SharedString,
51 pub items: Vec<Item>,
52}
53
54impl Menu {
55 pub fn new(title: impl Into<SharedString>, items: Vec<Item>) -> Self {
56 Self {
57 title: title.into(),
58 items,
59 }
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Item {
70 Action {
71 label: SharedString,
72 keystroke: Option<SharedString>,
76 enabled: bool,
77 },
78 Separator,
79}
80
81impl Item {
82 pub fn action(label: impl Into<SharedString>) -> Self {
83 Item::Action {
84 label: label.into(),
85 keystroke: None,
86 enabled: true,
87 }
88 }
89
90 pub fn with_keystroke(self, keystroke: impl Into<SharedString>) -> Self {
92 match self {
93 Item::Action { label, enabled, .. } => Item::Action {
94 label,
95 keystroke: Some(keystroke.into()),
96 enabled,
97 },
98 Item::Separator => Item::Separator,
99 }
100 }
101
102 pub fn disabled(self) -> Self {
103 match self {
104 Item::Action {
105 label, keystroke, ..
106 } => Item::Action {
107 label,
108 keystroke,
109 enabled: false,
110 },
111 Item::Separator => Item::Separator,
112 }
113 }
114
115 pub fn selectable(&self) -> bool {
117 matches!(self, Item::Action { enabled: true, .. })
118 }
119}
120
121pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
129 let count = items.len();
130 if count == 0 {
131 return None;
132 }
133 let step = if delta >= 0 { 1 } else { -1 };
134 let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
135 let mut at = match from {
138 None if step > 0 => 0,
139 None => count - 1,
140 Some(at) => wrap(at.min(count - 1)),
141 };
142 for _ in 0..count {
143 if items[at].selectable() {
144 return Some(at);
145 }
146 at = wrap(at);
147 }
148 None
149}
150
151actions!(
156 bezel_menubar,
157 [PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
158);
159
160pub const KEY_CONTEXT: &str = "Menubar";
163
164pub fn init(cx: &mut App) {
171 let ctx = Some(KEY_CONTEXT);
172 cx.bind_keys([
173 KeyBinding::new("left", PrevMenu, ctx),
174 KeyBinding::new("right", NextMenu, ctx),
175 KeyBinding::new("up", PrevItem, ctx),
176 KeyBinding::new("down", NextItem, ctx),
177 KeyBinding::new("enter", Confirm, ctx),
178 KeyBinding::new("escape", Dismiss, ctx),
179 ]);
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum MenubarEvent {
185 Selected { menu: usize, item: usize },
186}
187
188pub struct Menubar {
189 menus: Vec<Menu>,
190 open: popover::Popup<usize>,
194 highlighted: Option<usize>,
198 focus_handle: FocusHandle,
199}
200
201impl EventEmitter<MenubarEvent> for Menubar {}
202
203impl Menubar {
204 pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
205 Self {
206 menus,
207 open: popover::Popup::default(),
208 highlighted: None,
209 focus_handle: cx.focus_handle().tab_stop(true),
212 }
213 }
214
215 pub fn open_menu(&self) -> Option<usize> {
217 self.open.as_open().copied()
218 }
219
220 pub fn menus(&self) -> &[Menu] {
224 &self.menus
225 }
226
227 fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
228 self.open.open(menu);
229 self.highlighted = None;
230 window.focus(&self.focus_handle, cx);
231 cx.notify();
232 }
233
234 fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
235 if self.open.take_press_was_open() {
239 self.close(cx);
240 } else {
241 self.show(menu, window, cx);
242 }
243 }
244
245 fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
250 if self.open.is_open() && self.open_menu() != Some(menu) {
251 self.open.open(menu);
252 self.highlighted = None;
253 cx.notify();
254 }
255 }
256
257 fn close(&mut self, cx: &mut Context<Self>) {
258 if self.open.begin_close() {
259 popover::reap_popup(cx, |bar: &mut Self| &mut bar.open);
260 }
261 self.highlighted = None;
262 cx.notify();
263 }
264
265 fn choose(&mut self, menu: usize, item: usize, cx: &mut Context<Self>) {
266 cx.emit(MenubarEvent::Selected { menu, item });
267 self.close(cx);
268 }
269
270 fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
271 let Some(menu) = self.open_menu() else { return };
272 self.highlighted = next_selectable(&self.menus[menu].items, self.highlighted, delta);
273 cx.notify();
274 }
275
276 fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
277 let Some(menu) = self.open_menu() else { return };
278 let count = self.menus.len() as isize;
279 if count == 0 {
280 return;
281 }
282 self.open
283 .open((menu as isize + delta).rem_euclid(count) as usize);
284 self.highlighted = None;
285 cx.notify();
286 }
287
288 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
289 match (self.open_menu(), self.highlighted) {
290 (Some(menu), Some(item)) => self.choose(menu, item, cx),
291 (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
295 _ => {}
296 }
297 }
298
299 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
300 self.close(cx);
301 }
302
303 fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
304 let view = Painter::of(cx);
307 popover::popover_card(theme)
308 .min_w(px(180.0))
309 .children(
310 self.menus[menu]
311 .items
312 .iter()
313 .enumerate()
314 .map(|(index, item)| match item {
315 Item::Separator => popover::divider().into_any_element(),
316 Item::Action {
317 label,
318 keystroke,
319 enabled: false,
320 } => {
321 disabled_row(theme, label.clone(), keystroke.clone()).into_any_element()
322 }
323 Item::Action {
324 label, keystroke, ..
325 } => popover::menu_row_nav(
326 theme,
327 false,
328 self.highlighted == Some(index),
329 Fade::new(view, format!("menubar-{menu}-{index}")),
330 )
331 .justify_between()
332 .id(SharedString::from(format!("item-{menu}-{index}")))
333 .on_click(cx.listener(move |bar, _, _, cx| bar.choose(menu, index, cx)))
334 .child(label.clone())
335 .when_some(keystroke.clone(), |row, keystroke| {
336 row.child(popover::kbd_hint(theme, &keystroke))
337 })
338 .into_any_element(),
339 }),
340 )
341 .on_mouse_down_out(cx.listener(|bar, _, _, cx| bar.close(cx)))
342 .into_any_element()
343 }
344}
345
346pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
348 let title = div()
349 .px(px(8.0))
350 .py(px(3.0))
351 .rounded(px(Theme::control_radius()))
352 .text_size(px(13.0))
353 .cursor_pointer()
354 .child(label.into());
355 if open {
356 title.bg(ink(0.08)).text_color(theme.text)
357 } else {
358 title
362 .text_color(theme.text_muted)
363 .hover(|s| s.bg(ink(0.05)).text_color(theme.text))
364 }
365}
366
367pub fn menubar() -> gpui::Div {
369 div().flex().flex_row().items_center().gap(px(2.0))
370}
371
372fn disabled_row(theme: &Theme, label: SharedString, keystroke: Option<SharedString>) -> gpui::Div {
376 div()
377 .flex()
378 .flex_row()
379 .items_center()
380 .justify_between()
381 .gap(px(10.0))
382 .px(px(8.0))
383 .py(px(6.0))
384 .rounded(px(Theme::inset_radius(
387 Theme::surface_radius(),
388 popover::MENU_PAD,
389 )))
390 .text_size(px(13.0))
391 .text_color(theme.text_faint)
392 .child(label)
393 .when_some(keystroke, |row, keystroke| {
394 row.child(popover::kbd_hint(theme, &keystroke))
395 })
396}
397
398impl Focusable for Menubar {
399 fn focus_handle(&self, _: &App) -> FocusHandle {
400 self.focus_handle.clone()
401 }
402}
403
404impl Render for Menubar {
405 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
406 let theme = Theme::of(cx).clone();
407 let mounted = self.open.get().copied();
409 let closing = self.open.closing_since();
410
411 menubar()
412 .key_context(KEY_CONTEXT)
413 .track_focus(&self.focus_handle)
414 .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.step_menu(-1, cx)))
415 .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.step_menu(1, cx)))
416 .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
417 .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
418 .on_action(cx.listener(Self::confirm))
419 .on_action(cx.listener(Self::dismiss))
420 .children((0..self.menus.len()).map(|menu| {
421 let down = mounted == Some(menu);
422 let card = down.then(|| self.card(menu, &theme, cx));
423 div()
424 .relative()
425 .id(SharedString::from(format!("menubar-title-{menu}")))
426 .on_mouse_down(
427 gpui::MouseButton::Left,
428 cx.listener(move |bar, _, _, _| {
429 bar.open.note_trigger_press_matching(|open| *open == menu)
430 }),
431 )
432 .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
433 .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
434 if *hovered {
435 bar.hover_switch(menu, cx);
436 }
437 }))
438 .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
439 .when_some(card, |title, card| {
440 title.child(popover::anchored_menu_below(
441 SharedString::from(format!("menubar-menu-{menu}")),
442 card,
443 closing,
444 ))
445 })
446 }))
447 }
448}