1use std::rc::Rc;
13
14use gpui::{
15 AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
16 IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
17 SharedString, StatefulInteractiveElement, Styled, Transformation, Window, div,
18 prelude::FluentBuilder, px,
19};
20use gpui_kit_assets::{Icon, icon};
21use gpui_kit_semantics::{NodeSpec, Role, Semantic};
22use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Space, Theme};
23
24use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
25use crate::display::icon::flips;
26use crate::foundation::direction::ActiveDirection;
27use crate::foundation::{Ident, Pressable, Sizable, StyledExt};
28use crate::motion;
29use crate::overlay::focus::FocusTrap;
30use crate::overlay::kbd::Kbd;
31use crate::overlay::layer::{Overlay, Placement, surface};
32use crate::overlay::popover::{self, MenuKey};
33
34const PANEL_MIN_WIDTH: f32 = 200.0;
36const GLYPH_SLOT: f32 = 16.0;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42enum MenuItemKind {
43 Command,
44 Check(bool),
46 Separator,
47 Section,
49 Submenu(Vec<MenuItem>),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct MenuItem {
58 id: SharedString,
59 label: SharedString,
60 kind: MenuItemKind,
61 shortcut: Option<SharedString>,
62 icon: Option<Icon>,
63 disabled: bool,
64}
65
66impl MenuItem {
67 pub fn command(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
69 Self {
70 id: id.into(),
71 label: label.into(),
72 kind: MenuItemKind::Command,
73 shortcut: None,
74 icon: None,
75 disabled: false,
76 }
77 }
78
79 pub fn check(
82 id: impl Into<SharedString>,
83 label: impl Into<SharedString>,
84 checked: bool,
85 ) -> Self {
86 Self {
87 kind: MenuItemKind::Check(checked),
88 ..Self::command(id, label)
89 }
90 }
91
92 pub fn separator(id: impl Into<SharedString>) -> Self {
95 Self {
96 kind: MenuItemKind::Separator,
97 ..Self::command(id, "")
98 }
99 }
100
101 pub fn section(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
103 Self {
104 kind: MenuItemKind::Section,
105 ..Self::command(id, label)
106 }
107 }
108
109 pub fn submenu(
111 id: impl Into<SharedString>,
112 label: impl Into<SharedString>,
113 items: impl IntoIterator<Item = MenuItem>,
114 ) -> Self {
115 Self {
116 kind: MenuItemKind::Submenu(items.into_iter().collect()),
117 ..Self::command(id, label)
118 }
119 }
120
121 pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
123 self.shortcut = Some(keystroke.into());
124 self
125 }
126
127 pub fn icon(mut self, glyph: Icon) -> Self {
128 self.icon = Some(glyph);
129 self
130 }
131
132 pub fn disabled(mut self, disabled: bool) -> Self {
134 self.disabled = disabled;
135 self
136 }
137
138 pub fn id(&self) -> &SharedString {
139 &self.id
140 }
141
142 pub fn label(&self) -> &SharedString {
143 &self.label
144 }
145
146 pub fn is_disabled(&self) -> bool {
147 self.disabled
148 }
149
150 fn is_selectable(&self) -> bool {
152 !self.disabled
153 && matches!(
154 self.kind,
155 MenuItemKind::Command | MenuItemKind::Check(_) | MenuItemKind::Submenu(_)
156 )
157 }
158
159 fn children(&self) -> Option<&[MenuItem]> {
160 match &self.kind {
161 MenuItemKind::Submenu(children) => Some(children),
162 _ => None,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169enum Activation {
170 Invoked(SharedString),
171 OpenedSubmenu,
172 Ignored,
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
181struct MenuState {
182 path: Vec<usize>,
184 active: Option<usize>,
187}
188
189fn level<'a>(items: &'a [MenuItem], path: &[usize]) -> &'a [MenuItem] {
191 let mut level = items;
192 for index in path {
193 match level.get(*index).and_then(MenuItem::children) {
194 Some(children) => level = children,
195 None => break,
196 }
197 }
198 level
199}
200
201fn item_at<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
202 let (last, parents) = path.split_last()?;
203 level(items, parents).get(*last)
204}
205
206fn first_selectable(items: &[MenuItem]) -> Option<usize> {
207 items.iter().position(MenuItem::is_selectable)
208}
209
210impl MenuState {
211 fn current<'a>(&self, items: &'a [MenuItem]) -> &'a [MenuItem] {
212 level(items, &self.path)
213 }
214
215 fn reset(&mut self) {
216 self.path.clear();
217 self.active = None;
218 }
219
220 fn step(&mut self, items: &[MenuItem], delta: isize) {
223 let level = self.current(items);
224 let count = level.len();
225 let Some(start) = popover::step(self.active, count, delta) else {
226 return;
227 };
228 let mut index = start;
229 for _ in 0..count {
230 if level[index].is_selectable() {
231 self.active = Some(index);
232 return;
233 }
234 index = (index as isize + delta.signum()).rem_euclid(count as isize) as usize;
235 }
236 }
237
238 fn jump(&mut self, items: &[MenuItem], letter: char) -> bool {
240 let labels: Vec<Option<&str>> = self
241 .current(items)
242 .iter()
243 .map(|item| item.is_selectable().then(|| item.label.as_ref()))
244 .collect();
245 match popover::jump_to(&labels, self.active, letter) {
246 Some(index) => {
247 self.active = Some(index);
248 true
249 }
250 None => false,
251 }
252 }
253
254 fn enter(&mut self, items: &[MenuItem]) -> bool {
256 let Some(active) = self.active else {
257 return false;
258 };
259 let Some(item) = self.current(items).get(active) else {
260 return false;
261 };
262 if !item.is_selectable() || item.children().is_none() {
263 return false;
264 }
265 self.path.push(active);
266 self.active = first_selectable(self.current(items));
267 true
268 }
269
270 fn leave(&mut self) -> bool {
273 match self.path.pop() {
274 Some(index) => {
275 self.active = Some(index);
276 true
277 }
278 None => false,
279 }
280 }
281
282 fn activate(&mut self, items: &[MenuItem], path: &[usize]) -> Activation {
283 let Some((last, parents)) = path.split_last() else {
284 return Activation::Ignored;
285 };
286 let Some(item) = level(items, parents).get(*last) else {
287 return Activation::Ignored;
288 };
289 if !item.is_selectable() {
290 return Activation::Ignored;
291 }
292 self.path = parents.to_vec();
294 self.active = Some(*last);
295 if item.children().is_some() {
296 self.path = path.to_vec();
297 self.active = first_selectable(self.current(items));
298 return Activation::OpenedSubmenu;
299 }
300 Activation::Invoked(item.id.clone())
301 }
302
303 fn path_to(items: &[MenuItem], id: &str) -> Option<Vec<usize>> {
305 for (index, item) in items.iter().enumerate() {
306 if item.id == id {
307 return Some(vec![index]);
308 }
309 if let Some(children) = item.children()
310 && let Some(mut rest) = Self::path_to(children, id)
311 {
312 rest.insert(0, index);
313 return Some(rest);
314 }
315 }
316 None
317 }
318}
319
320type Activate<V> = Rc<dyn Fn(&mut V, Vec<usize>, &mut Window, &mut Context<V>)>;
322
323fn panels<V: 'static>(
325 ident: &Ident,
326 items: &[MenuItem],
327 state: &MenuState,
328 root_parent: SharedString,
329 theme: &Theme,
330 cx: &mut Context<V>,
331 activate: Activate<V>,
332) -> gpui::Div {
333 let depth = state.path.len();
334 let mut rendered: Vec<AnyElement> = Vec::with_capacity(depth + 1);
335 for level_depth in 0..=depth {
336 let base = state.path[..level_depth].to_vec();
337 let parent = if level_depth == 0 {
338 root_parent.clone()
339 } else {
340 match item_at(items, &base) {
341 Some(item) => ident.child(item.id.as_ref()).semantic_id(),
342 None => root_parent.clone(),
343 }
344 };
345 let active = if level_depth == depth {
346 state.active
347 } else {
348 None
349 };
350 let opened = (level_depth < depth).then(|| state.path[level_depth]);
351 rendered.push(panel(
352 ident,
353 level(items, &base),
354 &base,
355 parent,
356 active,
357 opened,
358 theme,
359 cx,
360 activate.clone(),
361 ));
362 }
363
364 div()
365 .flex()
366 .flex_row()
367 .items_start()
368 .gap(px(theme.space(Space::Xs)))
369 .children(rendered)
370}
371
372#[allow(clippy::too_many_arguments)]
373fn panel<V: 'static>(
374 ident: &Ident,
375 items: &[MenuItem],
376 base: &[usize],
377 parent: SharedString,
378 active: Option<usize>,
379 opened: Option<usize>,
380 theme: &Theme,
381 cx: &mut Context<V>,
382 activate: Activate<V>,
383) -> AnyElement {
384 let rows = items
385 .iter()
386 .enumerate()
387 .map(|(index, item)| {
388 let mut path = base.to_vec();
389 path.push(index);
390 row(
391 ident,
392 item,
393 path,
394 parent.clone(),
395 active == Some(index),
396 opened == Some(index),
397 index,
398 items.len(),
399 theme,
400 cx,
401 activate.clone(),
402 )
403 })
404 .collect::<Vec<_>>();
405
406 surface(theme, Elevation::Overlay)
407 .min_w(px(PANEL_MIN_WIDTH))
408 .p_token(theme, Space::Xs)
409 .children(rows)
410 .into_any_element()
411}
412
413#[allow(clippy::too_many_arguments)]
414fn row<V: 'static>(
415 ident: &Ident,
416 item: &MenuItem,
417 path: Vec<usize>,
418 parent: SharedString,
419 active: bool,
420 opened: bool,
421 index: usize,
422 count: usize,
423 theme: &Theme,
424 cx: &mut Context<V>,
425 activate: Activate<V>,
426) -> AnyElement {
427 let row_ident = ident.child(item.id.as_ref());
428 match &item.kind {
429 MenuItemKind::Separator => popover::separator(theme)
430 .semantic_in(
431 cx,
432 NodeSpec::new(row_ident.semantic_id(), Role::Separator).parent(parent),
433 )
434 .into_any_element(),
435 MenuItemKind::Section => popover::heading(theme, item.label.as_ref())
436 .semantic_in(
437 cx,
438 NodeSpec::new(row_ident.semantic_id(), Role::Heading)
439 .parent(parent)
440 .level(2)
441 .text(item.label.clone()),
442 )
443 .into_any_element(),
444 kind => {
445 let checked = match kind {
446 MenuItemKind::Check(checked) => Some(*checked),
447 _ => None,
448 };
449 let submenu = item.children().is_some();
450 let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
451 .parent(parent)
452 .text(item.label.clone())
453 .disabled(item.disabled)
454 .hovered(active);
455 if let Some(checked) = checked {
456 spec = spec.checked(checked);
457 }
458 if submenu {
459 spec = spec.expanded(opened);
460 }
461
462 let glyph = match (checked, item.icon) {
463 (Some(true), _) => Some(Icon::Check),
464 (Some(false), _) => None,
465 (None, glyph) => glyph,
466 };
467
468 let row =
469 popover::menu_row(theme, false, active || opened)
470 .id(row_ident.element_id())
471 .when(active, |element| element.aria_active_descendant())
472 .when(!item.disabled, |element| {
473 element.cursor_pointer().pressable(cx)
474 })
475 .when(item.disabled, |element| {
476 element.opacity(theme.opacity.disabled)
477 })
478 .child(
479 div()
480 .flex()
481 .flex_none()
482 .w(px(GLYPH_SLOT))
483 .justify_center()
484 .children(glyph.map(|glyph| {
485 icon(glyph).size(px(14.0)).text_color(theme.colors.text)
486 })),
487 )
488 .child(div().flex_1().child(item.label.clone()))
489 .children(
490 item.shortcut
491 .clone()
492 .map(|keystroke| Kbd::new(keystroke).into_any_element()),
493 )
494 .when(submenu, |element| {
495 let flipped = flips(Icon::AltArrowRight, cx.layout_direction());
498 element.child(
499 icon(Icon::AltArrowRight)
500 .size(px(12.0))
501 .text_color(theme.colors.text_muted)
502 .when(flipped, |glyph| {
503 glyph.with_transformation(Transformation::scale(gpui::size(
504 -1.0, 1.0,
505 )))
506 }),
507 )
508 })
509 .when(!item.disabled, |element| {
510 element.on_click(cx.listener(move |view, _, window, cx| {
511 activate(view, path.clone(), window, cx);
512 }))
513 })
514 .semantic_in(cx, spec);
515
516 motion::row_in(row_ident.child("in").element_id(), theme, index, count, row)
517 .into_any_element()
518 }
519 }
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
524pub enum MenuEvent {
525 Opened,
526 Invoked(SharedString),
529 Dismissed,
531 Closed,
532}
533
534impl EventEmitter<MenuEvent> for Menu {}
535
536pub struct Menu {
538 ident: Ident,
539 focus_handle: FocusHandle,
540 trigger_focus: FocusHandle,
541 trigger: SharedString,
542 trigger_icon: Option<Icon>,
543 trigger_name: Option<SharedString>,
545 trigger_variant: ButtonVariant,
546 trigger_join: ButtonJoin,
547 trigger_size: ControlSize,
548 items: Vec<MenuItem>,
549 placement: Placement,
550 open: bool,
551 pending_focus: bool,
553 state: MenuState,
554 trap: FocusTrap,
555}
556
557impl std::fmt::Debug for Menu {
558 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 formatter
560 .debug_struct("Menu")
561 .field("ident", &self.ident)
562 .field("trigger", &self.trigger)
563 .field("items", &self.items.len())
564 .field("open", &self.open)
565 .field("open_submenus", &self.state.path.len())
566 .finish()
567 }
568}
569
570impl Menu {
571 pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
572 Self {
573 ident: ident.into(),
574 focus_handle: cx.focus_handle(),
575 trigger_focus: cx.focus_handle(),
576 trigger: SharedString::default(),
577 trigger_icon: None,
578 trigger_name: None,
579 trigger_variant: ButtonVariant::Secondary,
580 trigger_join: ButtonJoin::Alone,
581 trigger_size: ControlSize::Md,
582 items: Vec::new(),
583 placement: Placement::Below,
584 open: false,
585 pending_focus: false,
586 state: MenuState::default(),
587 trap: FocusTrap::new(),
588 }
589 }
590
591 pub fn trigger(mut self, label: impl Into<SharedString>) -> Self {
593 self.trigger = label.into();
594 self
595 }
596
597 pub fn trigger_icon(mut self, glyph: Icon) -> Self {
598 self.trigger_icon = Some(glyph);
599 self
600 }
601
602 pub fn trigger_name(mut self, name: impl Into<SharedString>) -> Self {
605 self.trigger_name = Some(name.into());
606 self
607 }
608
609 pub fn set_trigger_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
610 self.trigger_name = Some(name.into());
611 cx.notify();
612 }
613
614 pub fn trigger_variant(mut self, variant: ButtonVariant) -> Self {
617 self.trigger_variant = variant;
618 self
619 }
620
621 pub fn trigger_join(mut self, join: ButtonJoin) -> Self {
623 self.trigger_join = join;
624 self
625 }
626
627 pub fn set_trigger_style(
630 &mut self,
631 variant: ButtonVariant,
632 size: ControlSize,
633 cx: &mut Context<Self>,
634 ) {
635 self.trigger_variant = variant;
636 self.trigger_size = size;
637 cx.notify();
638 }
639
640 pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
641 self.items = items.into_iter().collect();
642 self
643 }
644
645 pub fn placement(mut self, placement: Placement) -> Self {
646 self.placement = placement;
647 self
648 }
649
650 pub fn offered(&self) -> &[MenuItem] {
653 &self.items
654 }
655
656 pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
659 self.items = items;
660 self.state.reset();
661 cx.notify();
662 }
663
664 pub fn is_open(&self) -> bool {
665 self.open
666 }
667
668 pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
669 if self.open {
670 return;
671 }
672 self.open = true;
673 self.pending_focus = true;
674 self.state.reset();
675 self.state.active = first_selectable(&self.items);
676 self.trap.engage(window, cx);
677 cx.emit(MenuEvent::Opened);
678 cx.notify();
679 }
680
681 pub fn open_submenu(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
684 let Some(path) = MenuState::path_to(&self.items, id) else {
685 return false;
686 };
687 if item_at(&self.items, &path)
688 .and_then(MenuItem::children)
689 .is_none()
690 {
691 return false;
692 }
693 self.open(window, cx);
694 self.state.path = path;
695 self.state.active = first_selectable(self.state.current(&self.items));
696 cx.notify();
697 true
698 }
699
700 pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
703 if !self.open {
704 return;
705 }
706 self.open = false;
707 self.pending_focus = false;
708 self.state.reset();
709 self.trap.release(window, cx);
710 self.trigger_focus.focus(window, cx);
711 cx.emit(MenuEvent::Closed);
712 cx.notify();
713 }
714
715 pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
716 if !self.open {
717 return;
718 }
719 cx.emit(MenuEvent::Dismissed);
720 self.close(window, cx);
721 }
722
723 pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
724 if self.open {
725 self.dismiss(window, cx);
726 } else {
727 self.open(window, cx);
728 }
729 }
730
731 fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
732 match self.state.activate(&self.items, &path) {
733 Activation::Invoked(id) => {
734 cx.emit(MenuEvent::Invoked(id));
735 self.close(window, cx);
736 }
737 Activation::OpenedSubmenu => cx.notify(),
738 Activation::Ignored => {}
739 }
740 }
741
742 fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
743 if !self.open {
744 return;
745 }
746 let handled = handle_key(&mut self.state, &self.items, event);
747 match handled {
748 Handled::Moved => {
749 cx.notify();
750 cx.stop_propagation();
751 }
752 Handled::Activate(path) => {
753 self.take(path, window, cx);
754 cx.stop_propagation();
755 }
756 Handled::Close => {
757 self.dismiss(window, cx);
758 cx.stop_propagation();
759 }
760 Handled::None => {}
761 }
762 }
763}
764
765enum Handled {
767 Moved,
768 Activate(Vec<usize>),
769 Close,
771 None,
772}
773
774fn handle_key(state: &mut MenuState, items: &[MenuItem], event: &KeyDownEvent) -> Handled {
776 let key = popover::classify_key(
777 event.keystroke.key.as_str(),
778 event.keystroke.modifiers.platform,
779 event.keystroke.modifiers.control,
780 );
781 match key {
782 MenuKey::Down => {
783 state.step(items, 1);
784 Handled::Moved
785 }
786 MenuKey::Up => {
787 state.step(items, -1);
788 Handled::Moved
789 }
790 MenuKey::Right => {
795 if state.enter(items) {
796 Handled::Moved
797 } else {
798 Handled::None
799 }
800 }
801 MenuKey::Left => {
802 if state.leave() {
803 Handled::Moved
804 } else {
805 Handled::None
806 }
807 }
808 MenuKey::Enter => match state.active {
809 Some(active) => {
810 let mut path = state.path.clone();
811 path.push(active);
812 Handled::Activate(path)
813 }
814 None => Handled::Moved,
815 },
816 MenuKey::Escape => {
819 if state.leave() {
820 Handled::Moved
821 } else {
822 Handled::Close
823 }
824 }
825 _ => match popover::typed_letter(event.keystroke.key.as_str(), event.keystroke.modifiers) {
826 Some(letter) if state.jump(items, letter) => Handled::Moved,
827 _ => Handled::None,
828 },
829 }
830}
831
832impl Sizable for Menu {
833 fn control_size(mut self, size: ControlSize) -> Self {
834 self.trigger_size = size;
835 self
836 }
837}
838
839impl Focusable for Menu {
840 fn focus_handle(&self, _cx: &App) -> FocusHandle {
841 self.focus_handle.clone()
842 }
843}
844
845impl Render for Menu {
846 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
847 let theme = cx.theme().clone();
848 let menu = cx.entity().downgrade();
849 let glyph_only = self.trigger.is_empty();
850 let trigger = Button::new(self.ident.child("trigger"))
851 .label(self.trigger.clone())
852 .variant(self.trigger_variant)
853 .join(self.trigger_join)
854 .control_size(self.trigger_size)
855 .track_focus(&self.trigger_focus)
856 .when_some(self.trigger_icon, |button, glyph| {
857 match (glyph_only, self.trigger_name.clone()) {
858 (true, Some(name)) => button.icon_only(glyph, name),
859 _ => button.icon(glyph),
860 }
861 })
862 .when_some(self.trigger_name.clone(), |button, name| {
863 button.accessible_name(name)
864 })
865 .on_click(move |window, cx| {
866 menu.update(cx, |menu, cx| menu.toggle(window, cx)).ok();
867 })
868 .into_any_element();
869
870 let overlay = self.open.then(|| {
871 if self.pending_focus {
872 self.pending_focus = false;
875 self.focus_handle.focus(window, cx);
876 }
877 let activate: Activate<Self> =
878 Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
879 let menu_id = self.ident.child("menu").semantic_id();
880 let menu_name = self
881 .trigger_name
882 .clone()
883 .unwrap_or_else(|| self.trigger.clone());
884 let content = panels(
885 &self.ident,
886 &self.items,
887 &self.state,
888 menu_id.clone(),
889 &theme,
890 cx,
891 activate,
892 )
893 .track_focus(&self.focus_handle)
894 .on_key_down(cx.listener(Self::on_key_down))
895 .on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
896 .semantic_in(
897 cx,
898 NodeSpec::new(menu_id, Role::Menu)
899 .parent(self.ident.semantic_id())
900 .text(menu_name)
901 .expanded(true)
902 .focus(&self.focus_handle),
903 );
904
905 Overlay::new(self.ident.child("overlay"))
906 .placement(self.placement)
907 .child(content)
908 .into_any_element()
909 });
910
911 popover::anchored_slot(self.placement, trigger, overlay).semantic_in(
912 cx,
913 NodeSpec::new(self.ident.semantic_id(), Role::Group)
914 .expanded(self.open)
915 .focus(&self.focus_handle),
916 )
917 }
918}
919
920#[derive(Debug, Clone, PartialEq, Eq)]
922pub enum ContextMenuEvent {
923 Opened(SharedString),
927 Invoked(SharedString),
928 Dismissed,
929 Closed,
930}
931
932impl EventEmitter<ContextMenuEvent> for ContextMenu {}
933
934type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
936
937pub struct ContextMenu {
940 ident: Ident,
941 focus_handle: FocusHandle,
942 name: SharedString,
943 target: Option<SharedString>,
944 items: Vec<MenuItem>,
945 content: Option<Content>,
946 open: bool,
947 position: Point<Pixels>,
948 pending_focus: bool,
949 state: MenuState,
950 trap: FocusTrap,
951}
952
953impl std::fmt::Debug for ContextMenu {
954 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955 formatter
956 .debug_struct("ContextMenu")
957 .field("ident", &self.ident)
958 .field("target", &self.target)
959 .field("items", &self.items.len())
960 .field("open", &self.open)
961 .finish()
962 }
963}
964
965impl ContextMenu {
966 pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
967 Self {
968 ident: ident.into(),
969 focus_handle: cx.focus_handle(),
970 name: SharedString::default(),
971 target: None,
972 items: Vec::new(),
973 content: None,
974 open: false,
975 position: gpui::point(px(0.0), px(0.0)),
976 pending_focus: false,
977 state: MenuState::default(),
978 trap: FocusTrap::new(),
979 }
980 }
981
982 pub fn menu(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
983 self.items = items.into_iter().collect();
984 self
985 }
986
987 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
989 self.name = name.into();
990 self
991 }
992
993 pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
994 self.name = name.into();
995 cx.notify();
996 }
997
998 pub fn target(mut self, target: impl Into<SharedString>) -> Self {
1001 self.target = Some(target.into());
1002 self
1003 }
1004
1005 pub fn content(
1007 mut self,
1008 content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
1009 ) -> Self {
1010 self.content = Some(Rc::new(content));
1011 self
1012 }
1013
1014 pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
1015 self.items = items;
1016 self.state.reset();
1017 cx.notify();
1018 }
1019
1020 pub fn is_open(&self) -> bool {
1021 self.open
1022 }
1023
1024 pub fn position(&self) -> Point<Pixels> {
1025 self.position
1026 }
1027
1028 pub fn open_at(
1031 &mut self,
1032 position: Point<Pixels>,
1033 window: &mut Window,
1034 cx: &mut Context<Self>,
1035 ) {
1036 self.position = position;
1037 self.state.reset();
1038 self.state.active = first_selectable(&self.items);
1039 if !self.open {
1040 self.open = true;
1041 self.pending_focus = true;
1042 self.trap.engage(window, cx);
1043 }
1044 let target = self
1045 .target
1046 .clone()
1047 .unwrap_or_else(|| self.ident.semantic_id());
1048 cx.emit(ContextMenuEvent::Opened(target));
1049 cx.notify();
1050 }
1051
1052 pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1053 if !self.open {
1054 return;
1055 }
1056 self.open = false;
1057 self.pending_focus = false;
1058 self.state.reset();
1059 self.trap.release(window, cx);
1060 cx.emit(ContextMenuEvent::Closed);
1061 cx.notify();
1062 }
1063
1064 pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1065 if !self.open {
1066 return;
1067 }
1068 cx.emit(ContextMenuEvent::Dismissed);
1069 self.close(window, cx);
1070 }
1071
1072 fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
1073 match self.state.activate(&self.items, &path) {
1074 Activation::Invoked(id) => {
1075 cx.emit(ContextMenuEvent::Invoked(id));
1076 self.close(window, cx);
1077 }
1078 Activation::OpenedSubmenu => cx.notify(),
1079 Activation::Ignored => {}
1080 }
1081 }
1082
1083 fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1084 if !self.open {
1085 return;
1086 }
1087 match handle_key(&mut self.state, &self.items, event) {
1088 Handled::Moved => {
1089 cx.notify();
1090 cx.stop_propagation();
1091 }
1092 Handled::Activate(path) => {
1093 self.take(path, window, cx);
1094 cx.stop_propagation();
1095 }
1096 Handled::Close => {
1097 self.dismiss(window, cx);
1098 cx.stop_propagation();
1099 }
1100 Handled::None => {}
1101 }
1102 }
1103}
1104
1105impl Focusable for ContextMenu {
1106 fn focus_handle(&self, _cx: &App) -> FocusHandle {
1107 self.focus_handle.clone()
1108 }
1109}
1110
1111impl Render for ContextMenu {
1112 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1113 let theme = cx.theme().clone();
1114 let region = self.content.clone().map(|content| content(window, cx));
1115 let menu_id = self.ident.child("menu").semantic_id();
1116
1117 let overlay = self.open.then(|| {
1118 if self.pending_focus {
1119 self.pending_focus = false;
1120 self.focus_handle.focus(window, cx);
1121 }
1122 let activate: Activate<Self> =
1123 Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
1124 let content = panels(
1125 &self.ident,
1126 &self.items,
1127 &self.state,
1128 menu_id.clone(),
1129 &theme,
1130 cx,
1131 activate,
1132 )
1133 .track_focus(&self.focus_handle)
1134 .on_key_down(cx.listener(Self::on_key_down))
1135 .on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
1136 .semantic_in(
1137 cx,
1138 NodeSpec::new(menu_id.clone(), Role::Menu)
1139 .parent(self.ident.semantic_id())
1140 .text(self.name.clone())
1141 .expanded(true)
1142 .focus(&self.focus_handle),
1143 );
1144
1145 Overlay::new(self.ident.child("overlay"))
1146 .placement(Placement::At(self.position))
1147 .child(content)
1148 .into_any_element()
1149 });
1150
1151 div()
1152 .id(self.ident.element_id())
1153 .on_mouse_down(
1154 MouseButton::Right,
1155 cx.listener(|menu, event: &MouseDownEvent, window, cx| {
1156 menu.open_at(event.position, window, cx);
1157 cx.stop_propagation();
1158 }),
1159 )
1160 .children(region)
1161 .children(overlay)
1162 .semantic_in(
1163 cx,
1164 NodeSpec::new(self.ident.semantic_id(), Role::Region).expanded(self.open),
1165 )
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172
1173 fn items() -> Vec<MenuItem> {
1174 vec![
1175 MenuItem::section("edit.section", "Edit"),
1176 MenuItem::command("edit.undo", "Undo").shortcut("cmd-z"),
1177 MenuItem::separator("edit.rule"),
1178 MenuItem::command("edit.paste", "Paste").disabled(true),
1179 MenuItem::check("edit.wrap", "Wrap lines", true),
1180 MenuItem::submenu(
1181 "edit.share",
1182 "Share",
1183 [
1184 MenuItem::command("edit.share.link", "Copy link"),
1185 MenuItem::command("edit.share.mail", "Send by mail"),
1186 ],
1187 ),
1188 ]
1189 }
1190
1191 fn cursor() -> MenuState {
1192 MenuState {
1193 path: Vec::new(),
1194 active: first_selectable(&items()),
1195 }
1196 }
1197
1198 #[test]
1199 fn the_cursor_starts_on_something_that_can_be_taken() {
1200 assert_eq!(cursor().active, Some(1));
1201 }
1202
1203 #[test]
1204 fn moving_steps_over_labels_rules_and_refusals() {
1205 let items = items();
1206 let mut state = cursor();
1207 state.step(&items, 1);
1208 assert_eq!(
1209 state.active,
1210 Some(4),
1211 "the rule and the refusal are skipped"
1212 );
1213 state.step(&items, 1);
1214 assert_eq!(state.active, Some(5));
1215 state.step(&items, 1);
1216 assert_eq!(state.active, Some(1), "the cursor wraps past the section");
1217 state.step(&items, -1);
1218 assert_eq!(state.active, Some(5));
1219 }
1220
1221 #[test]
1222 fn typing_a_letter_jumps_to_the_next_row_that_starts_with_it() {
1223 let items = items();
1224 let mut state = cursor();
1225 assert!(state.jump(&items, 'w'));
1226 assert_eq!(state.active, Some(4));
1227 assert!(!state.jump(&items, 'p'), "a refused row is not jumped to");
1228 }
1229
1230 #[test]
1231 fn a_submenu_is_entered_and_left_without_invoking_anything() {
1232 let items = items();
1233 let mut state = cursor();
1234 state.active = Some(5);
1235 assert!(state.enter(&items));
1236 assert_eq!(state.path, vec![5]);
1237 assert_eq!(state.active, Some(0));
1238
1239 state.step(&items, 1);
1240 assert_eq!(state.active, Some(1));
1241 assert!(state.leave());
1242 assert_eq!(state.path, Vec::<usize>::new());
1243 assert_eq!(state.active, Some(5), "the cursor returns to the submenu");
1244 assert!(!state.leave(), "there is nothing left to fold away");
1245 }
1246
1247 #[test]
1248 fn a_row_that_cannot_be_taken_reports_nothing() {
1249 let items = items();
1250 let mut state = cursor();
1251 assert_eq!(state.activate(&items, &[0]), Activation::Ignored);
1252 assert_eq!(state.activate(&items, &[2]), Activation::Ignored);
1253 assert_eq!(state.activate(&items, &[3]), Activation::Ignored);
1254 }
1255
1256 #[test]
1257 fn taking_a_checkable_row_reports_it_and_changes_nothing() {
1258 let items = items();
1259 let mut state = cursor();
1260 assert_eq!(
1261 state.activate(&items, &[4]),
1262 Activation::Invoked("edit.wrap".into())
1263 );
1264 assert_eq!(
1265 items[4].kind,
1266 MenuItemKind::Check(true),
1267 "the menu does not toggle its own item"
1268 );
1269 }
1270
1271 #[test]
1272 fn acting_in_an_outer_panel_folds_the_open_submenu_away() {
1273 let items = items();
1274 let mut state = cursor();
1275 state.activate(&items, &[5]);
1276 assert_eq!(state.path, vec![5]);
1277 assert_eq!(
1278 state.activate(&items, &[1]),
1279 Activation::Invoked("edit.undo".into())
1280 );
1281 assert_eq!(state.path, Vec::<usize>::new());
1282 }
1283
1284 #[test]
1285 fn an_item_is_addressed_by_identity_at_any_depth() {
1286 let items = items();
1287 assert_eq!(MenuState::path_to(&items, "edit.share"), Some(vec![5]));
1288 assert_eq!(
1289 MenuState::path_to(&items, "edit.share.mail"),
1290 Some(vec![5, 1])
1291 );
1292 assert_eq!(MenuState::path_to(&items, "edit.nothing"), None);
1293 }
1294}