1use std::rc::Rc;
2
3use gpui::{
4 anchored, deferred, div, point, prelude::*, px, relative, AnyElement, App, Entity, FocusHandle,
5 FontWeight, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, Point, RenderOnce,
6 SharedString, StyleRefinement, Styled, Window,
7};
8
9use crate::chrome::box_shadow;
10use crate::compat::{AccessibilityExt, Role, StyleCompatExt};
11
12use crate::icon::{Icon, IconName};
13use crate::kbd::Kbd;
14use crate::motion::{Motion, StyledSlot};
15use crate::theme::{paint, ActiveTheme, Theme, ThemeKind};
16
17type MenuAction = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
18pub(crate) type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
19
20#[derive(Clone)]
21pub enum DropdownMenuEntry {
22 Item(DropdownMenuItem),
23 Separator,
24}
25
26impl DropdownMenuEntry {
27 pub fn item(label: impl Into<SharedString>) -> Self {
28 Self::Item(DropdownMenuItem::new(label))
29 }
30
31 pub fn separator() -> Self {
32 Self::Separator
33 }
34}
35
36#[derive(Clone)]
37pub struct DropdownMenuItem {
38 label: SharedString,
39 disabled: bool,
40 destructive: bool,
41 shortcut: Option<SharedString>,
42 submenu: Vec<DropdownMenuEntry>,
43 on_select: Option<MenuAction>,
44}
45
46impl DropdownMenuItem {
47 pub fn new(label: impl Into<SharedString>) -> Self {
48 Self {
49 label: label.into(),
50 disabled: false,
51 destructive: false,
52 shortcut: None,
53 submenu: Vec::new(),
54 on_select: None,
55 }
56 }
57
58 pub fn disabled(mut self, disabled: bool) -> Self {
59 self.disabled = disabled;
60 self
61 }
62
63 pub fn destructive(mut self, destructive: bool) -> Self {
64 self.destructive = destructive;
65 self
66 }
67
68 pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
69 self.shortcut = Some(shortcut.into());
70 self
71 }
72
73 pub fn submenu(mut self, entries: impl IntoIterator<Item = DropdownMenuEntry>) -> Self {
74 self.submenu = entries.into_iter().collect();
75 self
76 }
77
78 pub fn on_select(mut self, listener: impl Fn(&mut Window, &mut App) + 'static) -> Self {
79 self.on_select = Some(Rc::new(listener));
80 self
81 }
82}
83
84impl From<DropdownMenuItem> for DropdownMenuEntry {
85 fn from(item: DropdownMenuItem) -> Self {
86 Self::Item(item)
87 }
88}
89
90pub(crate) struct DropdownMenuState {
91 pub(crate) focus_handle: FocusHandle,
92 pub(crate) open: bool,
93 pub(crate) highlighted: Option<usize>,
94 pub(crate) submenu: Option<SubmenuState>,
95 pub(crate) origin: Point<Pixels>,
96 pub(crate) origin_window: bool,
97 pub(crate) previous_focus: Option<FocusHandle>,
98}
99
100#[derive(Clone, Copy)]
101pub(crate) struct SubmenuState {
102 pub(crate) parent: usize,
103 pub(crate) highlighted: Option<usize>,
104}
105
106pub(crate) fn item_at(entries: &[DropdownMenuEntry], index: usize) -> Option<&DropdownMenuItem> {
107 match entries.get(index) {
108 Some(DropdownMenuEntry::Item(item)) => Some(item),
109 _ => None,
110 }
111}
112
113fn enabled(entries: &[DropdownMenuEntry], index: usize) -> bool {
114 item_at(entries, index).is_some_and(|item| !item.disabled)
115}
116
117pub(crate) fn initial_highlight(entries: &[DropdownMenuEntry]) -> Option<usize> {
118 entries
119 .iter()
120 .enumerate()
121 .find_map(|(index, _)| enabled(entries, index).then_some(index))
122}
123
124pub(crate) fn next_enabled(
125 entries: &[DropdownMenuEntry],
126 current: Option<usize>,
127 forward: bool,
128) -> Option<usize> {
129 if entries.is_empty() {
130 return None;
131 }
132
133 match current {
134 Some(start) => (1..=entries.len())
135 .map(|offset| {
136 if forward {
137 (start + offset) % entries.len()
138 } else {
139 (start + entries.len() - (offset % entries.len())) % entries.len()
140 }
141 })
142 .find(|index| enabled(entries, *index)),
143 None if forward => initial_highlight(entries),
144 None => entries
145 .iter()
146 .enumerate()
147 .rev()
148 .find_map(|(index, _)| enabled(entries, index).then_some(index)),
149 }
150}
151
152pub(crate) fn set_open(
153 state: &Entity<DropdownMenuState>,
154 open: bool,
155 entries: &[DropdownMenuEntry],
156 on_open_change: Option<&OpenChangeHandler>,
157 window: &mut Window,
158 cx: &mut App,
159) {
160 if state.read(cx).open == open {
161 return;
162 }
163
164 state.update(cx, |menu, cx| {
165 menu.open = open;
166 menu.highlighted = open.then(|| initial_highlight(entries)).flatten();
167 menu.submenu = None;
168 cx.notify();
169 });
170
171 if let Some(on_open_change) = on_open_change {
172 on_open_change(open, window, cx);
173 }
174 window.refresh();
175}
176
177pub(crate) fn activate_item(
178 item: &DropdownMenuItem,
179 state: &Entity<DropdownMenuState>,
180 entries: &[DropdownMenuEntry],
181 on_open_change: Option<&OpenChangeHandler>,
182 focus_handle: &FocusHandle,
183 window: &mut Window,
184 cx: &mut App,
185) {
186 if item.disabled {
187 return;
188 }
189
190 set_open(state, false, entries, on_open_change, window, cx);
191 focus_handle.focus(window);
192 if let Some(on_select) = &item.on_select {
193 on_select(window, cx);
194 }
195}
196
197pub(crate) fn handle_menu_keydown(
198 event: &KeyDownEvent,
199 state: &Entity<DropdownMenuState>,
200 entries: &[DropdownMenuEntry],
201 on_open_change: Option<&OpenChangeHandler>,
202 focus_handle: &FocusHandle,
203 window: &mut Window,
204 cx: &mut App,
205) {
206 if event.keystroke.modifiers.modified() {
207 return;
208 }
209
210 let key = event.keystroke.key.as_str();
211 let snapshot = state.read(cx);
212 let was_open = snapshot.open;
213 let root_highlighted = snapshot.highlighted;
214 let submenu_state = snapshot.submenu;
215
216 match key {
217 "down" | "up" => {
218 let forward = key == "down";
219 state.update(cx, |menu, cx| {
220 if !menu.open {
221 menu.open = true;
222 menu.highlighted = initial_highlight(entries);
223 } else if let Some(submenu) = &mut menu.submenu {
224 if let Some(parent) = item_at(entries, submenu.parent) {
225 submenu.highlighted =
226 next_enabled(&parent.submenu, submenu.highlighted, forward);
227 }
228 } else {
229 menu.highlighted = next_enabled(entries, menu.highlighted, forward);
230 }
231 cx.notify();
232 });
233 if !was_open {
234 if let Some(on_open_change) = on_open_change {
235 on_open_change(true, window, cx);
236 }
237 }
238 cx.stop_propagation();
239 }
240 "enter" | "space" if !was_open => {
241 set_open(state, true, entries, on_open_change, window, cx);
242 cx.stop_propagation();
243 }
244 "right" if was_open && submenu_state.is_none() => {
245 if let Some((parent, item)) = root_highlighted
246 .and_then(|index| item_at(entries, index).map(|item| (index, item)))
247 .filter(|(_, item)| !item.disabled && !item.submenu.is_empty())
248 {
249 state.update(cx, |menu, cx| {
250 menu.submenu = Some(SubmenuState {
251 parent,
252 highlighted: initial_highlight(&item.submenu),
253 });
254 cx.notify();
255 });
256 cx.stop_propagation();
257 }
258 }
259 "left" if submenu_state.is_some() => {
260 state.update(cx, |menu, cx| {
261 menu.submenu = None;
262 cx.notify();
263 });
264 cx.stop_propagation();
265 }
266 "enter" | "space" if was_open => {
267 let picked = if let Some(submenu) = submenu_state {
268 item_at(entries, submenu.parent).and_then(|parent| {
269 submenu
270 .highlighted
271 .and_then(|index| item_at(&parent.submenu, index))
272 })
273 } else {
274 root_highlighted.and_then(|index| item_at(entries, index))
275 };
276
277 if let Some(item) = picked.filter(|item| !item.disabled) {
278 if !item.submenu.is_empty() && submenu_state.is_none() {
279 let parent = root_highlighted.expect("highlighted root item");
280 state.update(cx, |menu, cx| {
281 menu.submenu = Some(SubmenuState {
282 parent,
283 highlighted: initial_highlight(&item.submenu),
284 });
285 cx.notify();
286 });
287 window.refresh();
288 } else {
289 activate_item(
290 item,
291 state,
292 entries,
293 on_open_change,
294 focus_handle,
295 window,
296 cx,
297 );
298 }
299 }
300 cx.stop_propagation();
301 }
302 "escape" if was_open => {
303 set_open(state, false, entries, on_open_change, window, cx);
304 focus_handle.focus(window);
305 cx.stop_propagation();
306 }
307 _ => {}
308 }
309}
310
311#[derive(Clone, Copy)]
312struct MenuChrome {
313 background: gpui::Hsla,
314 border: gpui::Hsla,
315 inset: gpui::Hsla,
316 shadow: gpui::Hsla,
317 highlight: gpui::Hsla,
318}
319
320fn menu_chrome(theme: Theme) -> MenuChrome {
321 match theme.kind {
322 ThemeKind::Light => MenuChrome {
323 background: paint(0xFFFFFF85),
324 border: paint(0xFFFFFFB8),
325 inset: paint(0xFFFFFFE6),
326 shadow: paint(0x0F172A0F),
327 highlight: paint(0xFFFFFF47),
328 },
329 ThemeKind::Dark => MenuChrome {
330 background: paint(0xFFFFFF12),
331 border: paint(0xFFFFFF1A),
332 inset: paint(0xFFFFFF1F),
333 shadow: paint(0x00000047),
334 highlight: paint(0xFFFFFF12),
335 },
336 }
337}
338
339#[derive(Clone)]
340pub(crate) struct MenuPanelContext {
341 pub(crate) state: Entity<DropdownMenuState>,
342 pub(crate) root_entries: Vec<DropdownMenuEntry>,
343 pub(crate) focus_handle: FocusHandle,
344 pub(crate) on_open_change: Option<OpenChangeHandler>,
345}
346
347pub(crate) fn render_panel(
348 id: SharedString,
349 entries: Vec<DropdownMenuEntry>,
350 highlighted: Option<usize>,
351 submenu_parent: Option<usize>,
352 context: MenuPanelContext,
353 outside_dismiss: bool,
354 cx: &mut App,
355) -> AnyElement {
356 let theme = cx.theme();
357 let chrome = menu_chrome(theme);
358 let panel_selector = format!("{id}-panel");
359 let surface_id = format!("{id}-surface");
360 let dismiss_state = context.state.clone();
361 let dismiss_entries = context.root_entries.clone();
362 let dismiss_change = context.on_open_change.clone();
363
364 let panel = div()
365 .id(SharedString::from(panel_selector.clone()))
366 .debug_selector(move || panel_selector.clone())
367 .role(Role::Menu)
368 .flex()
369 .flex_col()
370 .w(px(240.))
371 .flex_shrink_0()
372 .p(px(4.))
373 .gap(px(2.))
374 .rounded(px(6.))
375 .border_1()
376 .border_color(chrome.border)
377 .bg(chrome.background)
378 .shadow(vec![
379 box_shadow(0., 1., chrome.inset, 0., 0.),
380 box_shadow(0., 6., chrome.shadow, 16., 0.),
381 ])
382 .occlude()
383 .when(outside_dismiss, |panel| {
384 panel.on_mouse_down_out(move |_, window, cx| {
385 set_open(
386 &dismiss_state,
387 false,
388 &dismiss_entries,
389 dismiss_change.as_ref(),
390 window,
391 cx,
392 );
393 })
394 })
395 .children(entries.into_iter().enumerate().map(|(index, entry)| {
396 let DropdownMenuEntry::Item(item) = entry else {
397 return div()
398 .id(SharedString::from(format!("{id}-separator-{index}")))
399 .debug_selector({
400 let selector = format!("{id}-separator-{index}");
401 move || selector.clone()
402 })
403 .w(px(232.))
404 .h(px(1.))
405 .flex_shrink_0()
406 .bg(if theme.is_dark() {
407 paint(0xFAFAFA1F)
408 } else {
409 paint(0x18181B1F)
410 })
411 .into_any_element();
412 };
413
414 let row_selector = format!("{id}-item-{index}");
415 let is_highlighted = highlighted == Some(index);
416 let is_submenu_open = submenu_parent == Some(index);
417 let has_submenu = !item.submenu.is_empty();
418 let interactive = !item.disabled;
419 let text_color = if item.disabled {
420 theme.label
421 } else if item.destructive {
422 theme.destructive
423 } else {
424 theme.ink
425 };
426 let click_item = item.clone();
427 let click_state = context.state.clone();
428 let click_entries = context.root_entries.clone();
429 let click_focus = context.focus_handle.clone();
430 let click_change = context.on_open_change.clone();
431 let submenu_entries = item.submenu.clone();
432
433 let mut row = div()
434 .id(SharedString::from(row_selector.clone()))
435 .debug_selector(move || row_selector.clone())
436 .role(Role::MenuItem)
437 .aria_selected(is_highlighted)
438 .when(has_submenu, |row| row.aria_expanded(is_submenu_open))
439 .relative()
440 .flex()
441 .items_center()
442 .justify_between()
443 .h(px(32.))
444 .flex_shrink_0()
445 .px(px(10.))
446 .rounded(px(4.))
447 .when(is_highlighted, |row| row.bg(chrome.highlight))
448 .when(interactive, |row| {
449 row.cursor_pointer()
450 .hover(move |style| style.bg(chrome.highlight))
451 })
452 .when(!interactive, |row| row.cursor_default())
453 .child(
454 div()
455 .min_w(px(0.))
456 .font_family(theme.font_family)
457 .font_weight(FontWeight::NORMAL)
458 .text_size(px(14.))
459 .line_height(px(18.))
460 .text_color(text_color)
461 .child(item.label.clone()),
462 )
463 .when_some(item.shortcut.clone(), |row, shortcut| {
464 row.child(Kbd::new(shortcut))
465 })
466 .when(has_submenu, |row| {
467 row.child(
468 Icon::new(IconName::ChevronRight)
469 .px(px(16.))
470 .color(theme.label),
471 )
472 });
473
474 if interactive {
475 row = row.on_click(move |_, window, cx| {
476 if has_submenu {
477 click_state.update(cx, |menu, cx| {
478 menu.highlighted = Some(index);
479 menu.submenu = Some(SubmenuState {
480 parent: index,
481 highlighted: initial_highlight(&submenu_entries),
482 });
483 cx.notify();
484 });
485 window.refresh();
486 } else {
487 activate_item(
488 &click_item,
489 &click_state,
490 &click_entries,
491 click_change.as_ref(),
492 &click_focus,
493 window,
494 cx,
495 );
496 }
497 });
498 }
499
500 if is_submenu_open {
501 let submenu = item.submenu.clone();
502 let submenu_highlighted = context
503 .state
504 .read(cx)
505 .submenu
506 .and_then(|menu| menu.highlighted);
507 let submenu_panel = render_panel(
508 SharedString::from(format!("{id}-submenu-{index}")),
509 submenu,
510 submenu_highlighted,
511 None,
512 context.clone(),
513 true,
514 cx,
515 );
516 row = row.child(
517 div().absolute().left(relative(1.)).top(px(-4.)).child(
518 anchored()
519 .offset(point(px(6.), px(0.)))
520 .snap_to_window_with_margin(px(8.))
521 .child(submenu_panel),
522 ),
523 );
524 }
525
526 row.into_any_element()
527 }));
528
529 Motion::new()
530 .id(surface_id)
531 .surface_in()
532 .child(panel)
533 .into_any_element()
534}
535
536#[derive(IntoElement)]
538pub struct DropdownMenu {
539 id: SharedString,
540 controlled_open: Option<bool>,
541 default_open: bool,
542 trigger_label: SharedString,
543 trigger: Option<AnyElement>,
544 entries: Vec<DropdownMenuEntry>,
545 on_open_change: Option<OpenChangeHandler>,
546 style: StyleRefinement,
547}
548
549impl DropdownMenu {
550 pub fn new(id: impl Into<SharedString>) -> Self {
551 Self {
552 id: id.into(),
553 controlled_open: None,
554 default_open: false,
555 trigger_label: SharedString::from("Open menu"),
556 trigger: None,
557 entries: Vec::new(),
558 on_open_change: None,
559 style: StyleRefinement::default(),
560 }
561 }
562
563 pub fn open(mut self, open: bool) -> Self {
564 self.controlled_open = Some(open);
565 self
566 }
567
568 pub fn default_open(mut self, open: bool) -> Self {
569 self.default_open = open;
570 self
571 }
572
573 pub fn trigger_label(mut self, label: impl Into<SharedString>) -> Self {
574 self.trigger_label = label.into();
575 self
576 }
577
578 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
579 self.trigger = Some(trigger.into_any_element());
580 self
581 }
582
583 pub fn entries(mut self, entries: impl IntoIterator<Item = DropdownMenuEntry>) -> Self {
584 self.entries = entries.into_iter().collect();
585 self
586 }
587
588 pub fn on_open_change(
589 mut self,
590 listener: impl Fn(bool, &mut Window, &mut App) + 'static,
591 ) -> Self {
592 self.on_open_change = Some(Rc::new(listener));
593 self
594 }
595}
596
597impl Styled for DropdownMenu {
598 fn style(&mut self) -> &mut StyleRefinement {
599 &mut self.style
600 }
601}
602
603impl RenderOnce for DropdownMenu {
604 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
605 let has_trigger = self.trigger.is_some();
606 let initial_open = self.controlled_open.unwrap_or(self.default_open);
607 let initial_entries = self.entries.clone();
608 let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| DropdownMenuState {
609 focus_handle: cx.focus_handle().tab_stop(true),
610 open: initial_open,
611 highlighted: initial_open
612 .then(|| initial_highlight(&initial_entries))
613 .flatten(),
614 submenu: None,
615 origin: point(px(0.), px(0.)),
616 origin_window: false,
617 previous_focus: None,
618 });
619
620 if let Some(controlled_open) = self.controlled_open {
621 if state.read(cx).open != controlled_open {
622 state.update(cx, |menu, _| {
623 menu.open = controlled_open;
624 menu.highlighted = controlled_open
625 .then(|| initial_highlight(&self.entries))
626 .flatten();
627 menu.submenu = None;
628 });
629 }
630 }
631
632 let open = state.read(cx).open;
633 let highlighted = state.read(cx).highlighted;
634 let submenu = state.read(cx).submenu;
635 let focus_handle = state.read(cx).focus_handle.clone();
636 let trigger_selector = format!("{}-trigger", self.id);
637 let trigger_state = state.clone();
638 let trigger_entries = self.entries.clone();
639 let trigger_change = self.on_open_change.clone();
640 let trigger_focus = focus_handle.clone();
641 let keyboard_state = state.clone();
642 let keyboard_entries = self.entries.clone();
643 let keyboard_change = self.on_open_change.clone();
644 let keyboard_focus = focus_handle.clone();
645
646 let trigger = div()
647 .id(SharedString::from(trigger_selector.clone()))
648 .debug_selector(move || trigger_selector.clone())
649 .role(Role::Button)
650 .aria_label(self.trigger_label)
651 .aria_expanded(open)
652 .track_focus(&focus_handle)
653 .tab_stop(true)
654 .relative()
655 .flex()
656 .items_center()
657 .cursor_pointer()
658 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
659 trigger_focus.focus(window);
660 let next_open = !trigger_state.read(cx).open;
661 set_open(
662 &trigger_state,
663 next_open,
664 &trigger_entries,
665 trigger_change.as_ref(),
666 window,
667 cx,
668 );
669 })
670 .on_key_down(move |event: &KeyDownEvent, window, cx| {
671 handle_menu_keydown(
672 event,
673 &keyboard_state,
674 &keyboard_entries,
675 keyboard_change.as_ref(),
676 &keyboard_focus,
677 window,
678 cx,
679 );
680 })
681 .when_some(self.trigger, |trigger, content| trigger.child(content));
682
683 let panel_context = MenuPanelContext {
684 state,
685 root_entries: self.entries.clone(),
686 focus_handle,
687 on_open_change: self.on_open_change,
688 };
689 let panel = render_panel(
690 self.id.clone(),
691 self.entries.clone(),
692 highlighted,
693 submenu.map(|submenu| submenu.parent),
694 panel_context,
695 submenu.is_none(),
696 cx,
697 );
698 let content = if has_trigger {
699 div()
700 .absolute()
701 .left(px(0.))
702 .top(relative(1.))
703 .child(
704 deferred(
705 anchored()
706 .offset(point(px(0.), px(6.)))
707 .snap_to_window_with_margin(px(8.))
708 .child(panel),
709 )
710 .with_priority(2),
711 )
712 .into_any_element()
713 } else {
714 panel
715 };
716
717 div()
718 .relative()
719 .flex()
720 .flex_none()
721 .self_start()
722 .items_center()
723 .refine_style(&self.style)
724 .when(has_trigger, |menu| menu.child(trigger))
725 .when(open, |menu| menu.child(content))
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn materials_match_paper() {
735 let light = menu_chrome(Theme::light());
736 assert_eq!(light.background, paint(0xFFFFFF85));
737 assert_eq!(light.border, paint(0xFFFFFFB8));
738 assert_eq!(light.highlight, paint(0xFFFFFF47));
739
740 let dark = menu_chrome(Theme::dark());
741 assert_eq!(dark.background, paint(0xFFFFFF12));
742 assert_eq!(dark.border, paint(0xFFFFFF1A));
743 assert_eq!(dark.highlight, paint(0xFFFFFF12));
744 }
745
746 #[test]
747 fn navigation_skips_separators_and_disabled_items() {
748 let entries = vec![
749 DropdownMenuEntry::separator(),
750 DropdownMenuItem::new("Disabled").disabled(true).into(),
751 DropdownMenuItem::new("Ready").into(),
752 ];
753 assert_eq!(initial_highlight(&entries), Some(2));
754 assert_eq!(next_enabled(&entries, Some(2), true), Some(2));
755 }
756}