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