1use crate::{
2 Icon, IconButtonShape, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator,
3 ListSubHeader, h_flex, prelude::*, utils::WithRemSize, v_flex,
4};
5use gpui::{
6 Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle,
7 Focusable, IntoElement, Render, Subscription, px,
8};
9use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
10use settings::Settings;
11use std::{rc::Rc, time::Duration};
12use theme::ThemeSettings;
13
14use super::Tooltip;
15
16pub enum ContextMenuItem {
17 Separator,
18 Header(SharedString),
19 /// title, link_label, link_url
20 HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
21 Label(SharedString),
22 Entry(ContextMenuEntry),
23 CustomEntry {
24 entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
25 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
26 selectable: bool,
27 documentation_aside: Option<DocumentationAside>,
28 },
29}
30
31impl ContextMenuItem {
32 pub fn custom_entry(
33 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
34 handler: impl Fn(&mut Window, &mut App) + 'static,
35 documentation_aside: Option<DocumentationAside>,
36 ) -> Self {
37 Self::CustomEntry {
38 entry_render: Box::new(entry_render),
39 handler: Rc::new(move |_, window, cx| handler(window, cx)),
40 selectable: true,
41 documentation_aside,
42 }
43 }
44}
45
46pub struct ContextMenuEntry {
47 toggle: Option<(IconPosition, bool)>,
48 label: SharedString,
49 icon: Option<IconName>,
50 icon_position: IconPosition,
51 icon_size: IconSize,
52 icon_color: Option<Color>,
53 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
54 action: Option<Box<dyn Action>>,
55 disabled: bool,
56 documentation_aside: Option<DocumentationAside>,
57 end_slot_icon: Option<IconName>,
58 end_slot_title: Option<SharedString>,
59 end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
60 show_end_slot_on_hover: bool,
61}
62
63impl ContextMenuEntry {
64 pub fn new(label: impl Into<SharedString>) -> Self {
65 ContextMenuEntry {
66 toggle: None,
67 label: label.into(),
68 icon: None,
69 icon_position: IconPosition::Start,
70 icon_size: IconSize::Small,
71 icon_color: None,
72 handler: Rc::new(|_, _, _| {}),
73 action: None,
74 disabled: false,
75 documentation_aside: None,
76 end_slot_icon: None,
77 end_slot_title: None,
78 end_slot_handler: None,
79 show_end_slot_on_hover: false,
80 }
81 }
82
83 pub fn toggleable(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
84 self.toggle = Some((toggle_position, toggled));
85 self
86 }
87
88 pub fn icon(mut self, icon: IconName) -> Self {
89 self.icon = Some(icon);
90 self
91 }
92
93 pub fn icon_position(mut self, position: IconPosition) -> Self {
94 self.icon_position = position;
95 self
96 }
97
98 pub fn icon_size(mut self, icon_size: IconSize) -> Self {
99 self.icon_size = icon_size;
100 self
101 }
102
103 pub fn icon_color(mut self, icon_color: Color) -> Self {
104 self.icon_color = Some(icon_color);
105 self
106 }
107
108 pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
109 self.toggle = Some((toggle_position, toggled));
110 self
111 }
112
113 pub fn action(mut self, action: Box<dyn Action>) -> Self {
114 self.action = Some(action);
115 self
116 }
117
118 pub fn handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
119 self.handler = Rc::new(move |_, window, cx| handler(window, cx));
120 self
121 }
122
123 pub fn disabled(mut self, disabled: bool) -> Self {
124 self.disabled = disabled;
125 self
126 }
127
128 pub fn documentation_aside(
129 mut self,
130 side: DocumentationSide,
131 edge: DocumentationEdge,
132 render: impl Fn(&mut App) -> AnyElement + 'static,
133 ) -> Self {
134 self.documentation_aside = Some(DocumentationAside {
135 side,
136 edge,
137 render: Rc::new(render),
138 });
139
140 self
141 }
142}
143
144impl FluentBuilder for ContextMenuEntry {}
145
146impl From<ContextMenuEntry> for ContextMenuItem {
147 fn from(entry: ContextMenuEntry) -> Self {
148 ContextMenuItem::Entry(entry)
149 }
150}
151
152pub struct ContextMenu {
153 builder: Option<Rc<dyn Fn(Self, &mut Window, &mut Context<Self>) -> Self>>,
154 items: Vec<ContextMenuItem>,
155 focus_handle: FocusHandle,
156 action_context: Option<FocusHandle>,
157 selected_index: Option<usize>,
158 delayed: bool,
159 clicked: bool,
160 end_slot_action: Option<Box<dyn Action>>,
161 key_context: SharedString,
162 _on_blur_subscription: Subscription,
163 keep_open_on_confirm: bool,
164 documentation_aside: Option<(usize, DocumentationAside)>,
165 fixed_width: Option<DefiniteLength>,
166}
167
168#[derive(Copy, Clone, PartialEq, Eq)]
169pub enum DocumentationSide {
170 Left,
171 Right,
172}
173
174#[derive(Copy, Default, Clone, PartialEq, Eq)]
175pub enum DocumentationEdge {
176 #[default]
177 Top,
178 Bottom,
179}
180
181#[derive(Clone)]
182pub struct DocumentationAside {
183 pub side: DocumentationSide,
184 pub edge: DocumentationEdge,
185 pub render: Rc<dyn Fn(&mut App) -> AnyElement>,
186}
187
188impl DocumentationAside {
189 pub fn new(
190 side: DocumentationSide,
191 edge: DocumentationEdge,
192 render: Rc<dyn Fn(&mut App) -> AnyElement>,
193 ) -> Self {
194 Self { side, edge, render }
195 }
196}
197
198impl Focusable for ContextMenu {
199 fn focus_handle(&self, _cx: &App) -> FocusHandle {
200 self.focus_handle.clone()
201 }
202}
203
204impl EventEmitter<DismissEvent> for ContextMenu {}
205
206impl FluentBuilder for ContextMenu {}
207
208impl ContextMenu {
209 pub fn new(
210 window: &mut Window,
211 cx: &mut Context<Self>,
212 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
213 ) -> Self {
214 let focus_handle = cx.focus_handle();
215 let _on_blur_subscription = cx.on_blur(
216 &focus_handle,
217 window,
218 |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
219 );
220 window.refresh();
221
222 f(
223 Self {
224 builder: None,
225 items: Default::default(),
226 focus_handle,
227 action_context: None,
228 selected_index: None,
229 delayed: false,
230 clicked: false,
231 key_context: "menu".into(),
232 _on_blur_subscription,
233 keep_open_on_confirm: false,
234 documentation_aside: None,
235 fixed_width: None,
236 end_slot_action: None,
237 },
238 window,
239 cx,
240 )
241 }
242
243 pub fn build(
244 window: &mut Window,
245 cx: &mut App,
246 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
247 ) -> Entity<Self> {
248 cx.new(|cx| Self::new(window, cx, f))
249 }
250
251 /// Builds a [`ContextMenu`] that will stay open when making changes instead of closing after each confirmation.
252 ///
253 /// The main difference from [`ContextMenu::build`] is the type of the `builder`, as we need to be able to hold onto
254 /// it to call it again.
255 pub fn build_persistent(
256 window: &mut Window,
257 cx: &mut App,
258 builder: impl Fn(Self, &mut Window, &mut Context<Self>) -> Self + 'static,
259 ) -> Entity<Self> {
260 cx.new(|cx| {
261 let builder = Rc::new(builder);
262
263 let focus_handle = cx.focus_handle();
264 let _on_blur_subscription = cx.on_blur(
265 &focus_handle,
266 window,
267 |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
268 );
269 window.refresh();
270
271 (builder.clone())(
272 Self {
273 builder: Some(builder),
274 items: Default::default(),
275 focus_handle,
276 action_context: None,
277 selected_index: None,
278 delayed: false,
279 clicked: false,
280 key_context: "menu".into(),
281 _on_blur_subscription,
282 keep_open_on_confirm: true,
283 documentation_aside: None,
284 fixed_width: None,
285 end_slot_action: None,
286 },
287 window,
288 cx,
289 )
290 })
291 }
292
293 /// Rebuilds the menu.
294 ///
295 /// This is used to refresh the menu entries when entries are toggled when the menu is configured with
296 /// `keep_open_on_confirm = true`.
297 ///
298 /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is
299 /// a no-op.
300 pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
301 let Some(builder) = self.builder.clone() else {
302 return;
303 };
304
305 // The way we rebuild the menu is a bit of a hack.
306 let focus_handle = cx.focus_handle();
307 let new_menu = (builder.clone())(
308 Self {
309 builder: Some(builder),
310 items: Default::default(),
311 focus_handle: focus_handle.clone(),
312 action_context: None,
313 selected_index: None,
314 delayed: false,
315 clicked: false,
316 key_context: "menu".into(),
317 _on_blur_subscription: cx.on_blur(
318 &focus_handle,
319 window,
320 |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
321 ),
322 keep_open_on_confirm: false,
323 documentation_aside: None,
324 fixed_width: None,
325 end_slot_action: None,
326 },
327 window,
328 cx,
329 );
330
331 self.items = new_menu.items;
332
333 cx.notify();
334 }
335
336 pub fn context(mut self, focus: FocusHandle) -> Self {
337 self.action_context = Some(focus);
338 self
339 }
340
341 pub fn header(mut self, title: impl Into<SharedString>) -> Self {
342 self.items.push(ContextMenuItem::Header(title.into()));
343 self
344 }
345
346 pub fn header_with_link(
347 mut self,
348 title: impl Into<SharedString>,
349 link_label: impl Into<SharedString>,
350 link_url: impl Into<SharedString>,
351 ) -> Self {
352 self.items.push(ContextMenuItem::HeaderWithLink(
353 title.into(),
354 link_label.into(),
355 link_url.into(),
356 ));
357 self
358 }
359
360 pub fn separator(mut self) -> Self {
361 self.items.push(ContextMenuItem::Separator);
362 self
363 }
364
365 pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
366 self.items.extend(items.into_iter().map(Into::into));
367 self
368 }
369
370 pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
371 self.items.push(item.into());
372 self
373 }
374
375 pub fn push_item(&mut self, item: impl Into<ContextMenuItem>) {
376 self.items.push(item.into());
377 }
378
379 pub fn entry(
380 mut self,
381 label: impl Into<SharedString>,
382 action: Option<Box<dyn Action>>,
383 handler: impl Fn(&mut Window, &mut App) + 'static,
384 ) -> Self {
385 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
386 toggle: None,
387 label: label.into(),
388 handler: Rc::new(move |_, window, cx| handler(window, cx)),
389 icon: None,
390 icon_position: IconPosition::End,
391 icon_size: IconSize::Small,
392 icon_color: None,
393 action,
394 disabled: false,
395 documentation_aside: None,
396 end_slot_icon: None,
397 end_slot_title: None,
398 end_slot_handler: None,
399 show_end_slot_on_hover: false,
400 }));
401 self
402 }
403
404 pub fn entry_with_end_slot(
405 mut self,
406 label: impl Into<SharedString>,
407 action: Option<Box<dyn Action>>,
408 handler: impl Fn(&mut Window, &mut App) + 'static,
409 end_slot_icon: IconName,
410 end_slot_title: SharedString,
411 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
412 ) -> Self {
413 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
414 toggle: None,
415 label: label.into(),
416 handler: Rc::new(move |_, window, cx| handler(window, cx)),
417 icon: None,
418 icon_position: IconPosition::End,
419 icon_size: IconSize::Small,
420 icon_color: None,
421 action,
422 disabled: false,
423 documentation_aside: None,
424 end_slot_icon: Some(end_slot_icon),
425 end_slot_title: Some(end_slot_title),
426 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
427 show_end_slot_on_hover: false,
428 }));
429 self
430 }
431
432 pub fn entry_with_end_slot_on_hover(
433 mut self,
434 label: impl Into<SharedString>,
435 action: Option<Box<dyn Action>>,
436 handler: impl Fn(&mut Window, &mut App) + 'static,
437 end_slot_icon: IconName,
438 end_slot_title: SharedString,
439 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
440 ) -> Self {
441 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
442 toggle: None,
443 label: label.into(),
444 handler: Rc::new(move |_, window, cx| handler(window, cx)),
445 icon: None,
446 icon_position: IconPosition::End,
447 icon_size: IconSize::Small,
448 icon_color: None,
449 action,
450 disabled: false,
451 documentation_aside: None,
452 end_slot_icon: Some(end_slot_icon),
453 end_slot_title: Some(end_slot_title),
454 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
455 show_end_slot_on_hover: true,
456 }));
457 self
458 }
459
460 pub fn toggleable_entry(
461 mut self,
462 label: impl Into<SharedString>,
463 toggled: bool,
464 position: IconPosition,
465 action: Option<Box<dyn Action>>,
466 handler: impl Fn(&mut Window, &mut App) + 'static,
467 ) -> Self {
468 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
469 toggle: Some((position, toggled)),
470 label: label.into(),
471 handler: Rc::new(move |_, window, cx| handler(window, cx)),
472 icon: None,
473 icon_position: position,
474 icon_size: IconSize::Small,
475 icon_color: None,
476 action,
477 disabled: false,
478 documentation_aside: None,
479 end_slot_icon: None,
480 end_slot_title: None,
481 end_slot_handler: None,
482 show_end_slot_on_hover: false,
483 }));
484 self
485 }
486
487 pub fn custom_row(
488 mut self,
489 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
490 ) -> Self {
491 self.items.push(ContextMenuItem::CustomEntry {
492 entry_render: Box::new(entry_render),
493 handler: Rc::new(|_, _, _| {}),
494 selectable: false,
495 documentation_aside: None,
496 });
497 self
498 }
499
500 pub fn custom_entry(
501 mut self,
502 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
503 handler: impl Fn(&mut Window, &mut App) + 'static,
504 ) -> Self {
505 self.items.push(ContextMenuItem::CustomEntry {
506 entry_render: Box::new(entry_render),
507 handler: Rc::new(move |_, window, cx| handler(window, cx)),
508 selectable: true,
509 documentation_aside: None,
510 });
511 self
512 }
513
514 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
515 self.items.push(ContextMenuItem::Label(label.into()));
516 self
517 }
518
519 pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
520 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
521 toggle: None,
522 label: label.into(),
523 action: Some(action.boxed_clone()),
524 handler: Rc::new(move |context, window, cx| {
525 if let Some(context) = &context {
526 window.focus(context);
527 }
528 window.dispatch_action(action.boxed_clone(), cx);
529 }),
530 icon: None,
531 icon_position: IconPosition::End,
532 icon_size: IconSize::Small,
533 icon_color: None,
534 disabled: false,
535 documentation_aside: None,
536 end_slot_icon: None,
537 end_slot_title: None,
538 end_slot_handler: None,
539 show_end_slot_on_hover: false,
540 }));
541 self
542 }
543
544 pub fn action_disabled_when(
545 mut self,
546 disabled: bool,
547 label: impl Into<SharedString>,
548 action: Box<dyn Action>,
549 ) -> Self {
550 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
551 toggle: None,
552 label: label.into(),
553 action: Some(action.boxed_clone()),
554 handler: Rc::new(move |context, window, cx| {
555 if let Some(context) = &context {
556 window.focus(context);
557 }
558 window.dispatch_action(action.boxed_clone(), cx);
559 }),
560 icon: None,
561 icon_size: IconSize::Small,
562 icon_position: IconPosition::End,
563 icon_color: None,
564 disabled,
565 documentation_aside: None,
566 end_slot_icon: None,
567 end_slot_title: None,
568 end_slot_handler: None,
569 show_end_slot_on_hover: false,
570 }));
571 self
572 }
573
574 pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
575 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
576 toggle: None,
577 label: label.into(),
578 action: Some(action.boxed_clone()),
579 handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
580 icon: Some(IconName::ArrowUpRight),
581 icon_size: IconSize::XSmall,
582 icon_position: IconPosition::End,
583 icon_color: None,
584 disabled: false,
585 documentation_aside: None,
586 end_slot_icon: None,
587 end_slot_title: None,
588 end_slot_handler: None,
589 show_end_slot_on_hover: false,
590 }));
591 self
592 }
593
594 pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
595 self.keep_open_on_confirm = keep_open;
596 self
597 }
598
599 pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
600 let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
601 return;
602 };
603 let ContextMenuItem::Entry(entry) = entry else {
604 return;
605 };
606 let Some(handler) = entry.end_slot_handler.as_ref() else {
607 return;
608 };
609 handler(None, window, cx);
610 }
611
612 pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
613 self.fixed_width = Some(width);
614 self
615 }
616
617 pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
618 self.end_slot_action = Some(action);
619 self
620 }
621
622 pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
623 self.key_context = context.into();
624 self
625 }
626
627 pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
628 let context = self.action_context.as_ref();
629 if let Some(
630 ContextMenuItem::Entry(ContextMenuEntry {
631 handler,
632 disabled: false,
633 ..
634 })
635 | ContextMenuItem::CustomEntry { handler, .. },
636 ) = self.selected_index.and_then(|ix| self.items.get(ix))
637 {
638 (handler)(context, window, cx)
639 }
640
641 if self.keep_open_on_confirm {
642 self.rebuild(window, cx);
643 } else {
644 cx.emit(DismissEvent);
645 }
646 }
647
648 pub fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
649 cx.emit(DismissEvent);
650 cx.emit(DismissEvent);
651 }
652
653 pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
654 let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
655 return;
656 };
657 let ContextMenuItem::Entry(entry) = item else {
658 return;
659 };
660 let Some(handler) = entry.end_slot_handler.as_ref() else {
661 return;
662 };
663 handler(None, window, cx);
664 self.rebuild(window, cx);
665 cx.notify();
666 }
667
668 pub fn clear_selected(&mut self) {
669 self.selected_index = None;
670 }
671
672 pub fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
673 if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
674 self.select_index(ix, window, cx);
675 }
676 cx.notify();
677 }
678
679 pub fn select_last(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<usize> {
680 for (ix, item) in self.items.iter().enumerate().rev() {
681 if item.is_selectable() {
682 return self.select_index(ix, window, cx);
683 }
684 }
685 None
686 }
687
688 fn handle_select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
689 if self.select_last(window, cx).is_some() {
690 cx.notify();
691 }
692 }
693
694 pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
695 if let Some(ix) = self.selected_index {
696 let next_index = ix + 1;
697 if self.items.len() <= next_index {
698 self.select_first(&SelectFirst, window, cx);
699 return;
700 } else {
701 for (ix, item) in self.items.iter().enumerate().skip(next_index) {
702 if item.is_selectable() {
703 self.select_index(ix, window, cx);
704 cx.notify();
705 return;
706 }
707 }
708 }
709 }
710 self.select_first(&SelectFirst, window, cx);
711 }
712
713 pub fn select_previous(
714 &mut self,
715 _: &SelectPrevious,
716 window: &mut Window,
717 cx: &mut Context<Self>,
718 ) {
719 if let Some(ix) = self.selected_index {
720 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
721 if item.is_selectable() {
722 self.select_index(ix, window, cx);
723 cx.notify();
724 return;
725 }
726 }
727 }
728 self.handle_select_last(&SelectLast, window, cx);
729 }
730
731 fn select_index(
732 &mut self,
733 ix: usize,
734 _window: &mut Window,
735 _cx: &mut Context<Self>,
736 ) -> Option<usize> {
737 self.documentation_aside = None;
738 let item = self.items.get(ix)?;
739 if item.is_selectable() {
740 self.selected_index = Some(ix);
741 match item {
742 ContextMenuItem::Entry(entry) => {
743 if let Some(callback) = &entry.documentation_aside {
744 self.documentation_aside = Some((ix, callback.clone()));
745 }
746 }
747 ContextMenuItem::CustomEntry {
748 documentation_aside: Some(callback),
749 ..
750 } => {
751 self.documentation_aside = Some((ix, callback.clone()));
752 }
753 _ => (),
754 }
755 }
756 Some(ix)
757 }
758
759 pub fn on_action_dispatch(
760 &mut self,
761 dispatched: &dyn Action,
762 window: &mut Window,
763 cx: &mut Context<Self>,
764 ) {
765 if self.clicked {
766 cx.propagate();
767 return;
768 }
769
770 if let Some(ix) = self.items.iter().position(|item| {
771 if let ContextMenuItem::Entry(ContextMenuEntry {
772 action: Some(action),
773 disabled: false,
774 ..
775 }) = item
776 {
777 action.partial_eq(dispatched)
778 } else {
779 false
780 }
781 }) {
782 self.select_index(ix, window, cx);
783 self.delayed = true;
784 cx.notify();
785 let action = dispatched.boxed_clone();
786 cx.spawn_in(window, async move |this, cx| {
787 cx.background_executor()
788 .timer(Duration::from_millis(50))
789 .await;
790 cx.update(|window, cx| {
791 this.update(cx, |this, cx| {
792 this.cancel(&menu::Cancel, window, cx);
793 window.dispatch_action(action, cx);
794 })
795 })
796 })
797 .detach_and_log_err(cx);
798 } else {
799 cx.propagate()
800 }
801 }
802
803 pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
804 self._on_blur_subscription = new_subscription;
805 self
806 }
807
808 fn render_menu_item(
809 &self,
810 ix: usize,
811 item: &ContextMenuItem,
812 window: &mut Window,
813 cx: &mut Context<Self>,
814 ) -> impl IntoElement + use<> {
815 match item {
816 ContextMenuItem::Separator => ListSeparator.into_any_element(),
817 ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
818 .inset(true)
819 .into_any_element(),
820 ContextMenuItem::HeaderWithLink(header, label, url) => {
821 let url = url.clone();
822 let link_id = ElementId::Name(format!("link-{}", url).into());
823 ListSubHeader::new(header.clone())
824 .inset(true)
825 .end_slot(
826 Button::new(link_id, label.clone())
827 .color(Color::Muted)
828 .label_size(LabelSize::Small)
829 .size(ButtonSize::None)
830 .style(ButtonStyle::Transparent)
831 .on_click(move |_, _, cx| {
832 let url = url.clone();
833 cx.open_url(&url);
834 })
835 .into_any_element(),
836 )
837 .into_any_element()
838 }
839 ContextMenuItem::Label(label) => ListItem::new(ix)
840 .inset(true)
841 .disabled(true)
842 .child(Label::new(label.clone()))
843 .into_any_element(),
844 ContextMenuItem::Entry(entry) => {
845 self.render_menu_entry(ix, entry, cx).into_any_element()
846 }
847 ContextMenuItem::CustomEntry {
848 entry_render,
849 handler,
850 selectable,
851 ..
852 } => {
853 let handler = handler.clone();
854 let menu = cx.entity().downgrade();
855 let selectable = *selectable;
856 ListItem::new(ix)
857 .inset(true)
858 .toggle_state(if selectable {
859 Some(ix) == self.selected_index
860 } else {
861 false
862 })
863 .selectable(selectable)
864 .when(selectable, |item| {
865 item.on_click({
866 let context = self.action_context.clone();
867 let keep_open_on_confirm = self.keep_open_on_confirm;
868 move |_, window, cx| {
869 handler(context.as_ref(), window, cx);
870 menu.update(cx, |menu, cx| {
871 menu.clicked = true;
872
873 if keep_open_on_confirm {
874 menu.rebuild(window, cx);
875 } else {
876 cx.emit(DismissEvent);
877 }
878 })
879 .ok();
880 }
881 })
882 })
883 .child(entry_render(window, cx))
884 .into_any_element()
885 }
886 }
887 }
888
889 fn render_menu_entry(
890 &self,
891 ix: usize,
892 entry: &ContextMenuEntry,
893 cx: &mut Context<Self>,
894 ) -> impl IntoElement {
895 let ContextMenuEntry {
896 toggle,
897 label,
898 handler,
899 icon,
900 icon_position,
901 icon_size,
902 icon_color,
903 action,
904 disabled,
905 documentation_aside,
906 end_slot_icon,
907 end_slot_title,
908 end_slot_handler,
909 show_end_slot_on_hover,
910 } = entry;
911 let this = cx.weak_entity();
912
913 let handler = handler.clone();
914 let menu = cx.entity().downgrade();
915
916 let icon_color = if *disabled {
917 Color::Muted
918 } else if toggle.is_some() {
919 icon_color.unwrap_or(Color::Accent)
920 } else {
921 icon_color.unwrap_or(Color::Default)
922 };
923
924 let label_color = if *disabled {
925 Color::Disabled
926 } else {
927 Color::Default
928 };
929
930 let label_element = if let Some(icon_name) = icon {
931 h_flex()
932 .gap_1p5()
933 .when(
934 *icon_position == IconPosition::Start && toggle.is_none(),
935 |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
936 )
937 .child(Label::new(label.clone()).color(label_color).truncate())
938 .when(*icon_position == IconPosition::End, |flex| {
939 flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
940 })
941 .into_any_element()
942 } else {
943 Label::new(label.clone())
944 .color(label_color)
945 .truncate()
946 .into_any_element()
947 };
948
949 div()
950 .id(("context-menu-child", ix))
951 .when_some(documentation_aside.clone(), |this, documentation_aside| {
952 this.occlude()
953 .on_hover(cx.listener(move |menu, hovered, _, cx| {
954 if *hovered {
955 menu.documentation_aside = Some((ix, documentation_aside.clone()));
956 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
957 menu.documentation_aside = None;
958 }
959 cx.notify();
960 }))
961 })
962 .child(
963 ListItem::new(ix)
964 .group_name("label_container")
965 .inset(true)
966 .disabled(*disabled)
967 .toggle_state(Some(ix) == self.selected_index)
968 .when_some(*toggle, |list_item, (position, toggled)| {
969 let contents = div()
970 .flex_none()
971 .child(
972 Icon::new(icon.unwrap_or(IconName::Check))
973 .color(icon_color)
974 .size(*icon_size),
975 )
976 .when(!toggled, |contents| contents.invisible());
977
978 match position {
979 IconPosition::Start => list_item.start_slot(contents),
980 IconPosition::End => list_item.end_slot(contents),
981 }
982 })
983 .child(
984 h_flex()
985 .w_full()
986 .justify_between()
987 .child(label_element)
988 .debug_selector(|| format!("MENU_ITEM-{}", label))
989 .children(action.as_ref().map(|action| {
990 let binding = self
991 .action_context
992 .as_ref()
993 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
994 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
995
996 div()
997 .ml_4()
998 .child(binding.disabled(*disabled))
999 .when(*disabled && documentation_aside.is_some(), |parent| {
1000 parent.invisible()
1001 })
1002 }))
1003 .when(*disabled && documentation_aside.is_some(), |parent| {
1004 parent.child(
1005 Icon::new(IconName::Info)
1006 .size(IconSize::XSmall)
1007 .color(Color::Muted),
1008 )
1009 }),
1010 )
1011 .when_some(
1012 end_slot_icon
1013 .as_ref()
1014 .zip(self.end_slot_action.as_ref())
1015 .zip(end_slot_title.as_ref())
1016 .zip(end_slot_handler.as_ref()),
1017 |el, (((icon, action), title), handler)| {
1018 el.end_slot({
1019 let icon_button = IconButton::new("end-slot-icon", *icon)
1020 .shape(IconButtonShape::Square)
1021 .tooltip({
1022 let action_context = self.action_context.clone();
1023 let title = title.clone();
1024 let action = action.boxed_clone();
1025 move |_window, cx| {
1026 action_context
1027 .as_ref()
1028 .map(|focus| {
1029 Tooltip::for_action_in(
1030 title.clone(),
1031 &*action,
1032 focus,
1033 cx,
1034 )
1035 })
1036 .unwrap_or_else(|| {
1037 Tooltip::for_action(title.clone(), &*action, cx)
1038 })
1039 }
1040 })
1041 .on_click({
1042 let handler = handler.clone();
1043 move |_, window, cx| {
1044 handler(None, window, cx);
1045 this.update(cx, |this, cx| {
1046 this.rebuild(window, cx);
1047 cx.notify();
1048 })
1049 .ok();
1050 }
1051 });
1052
1053 if *show_end_slot_on_hover {
1054 div()
1055 .visible_on_hover("label_container")
1056 .child(icon_button)
1057 .into_any_element()
1058 } else {
1059 icon_button.into_any_element()
1060 }
1061 })
1062 },
1063 )
1064 .on_click({
1065 let context = self.action_context.clone();
1066 let keep_open_on_confirm = self.keep_open_on_confirm;
1067 move |_, window, cx| {
1068 handler(context.as_ref(), window, cx);
1069 menu.update(cx, |menu, cx| {
1070 menu.clicked = true;
1071 if keep_open_on_confirm {
1072 menu.rebuild(window, cx);
1073 } else {
1074 cx.emit(DismissEvent);
1075 }
1076 })
1077 .ok();
1078 }
1079 }),
1080 )
1081 .into_any_element()
1082 }
1083}
1084
1085impl ContextMenuItem {
1086 fn is_selectable(&self) -> bool {
1087 match self {
1088 ContextMenuItem::Header(_)
1089 | ContextMenuItem::HeaderWithLink(_, _, _)
1090 | ContextMenuItem::Separator
1091 | ContextMenuItem::Label { .. } => false,
1092 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1093 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1094 }
1095 }
1096}
1097
1098impl Render for ContextMenu {
1099 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1100 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1101 let window_size = window.viewport_size();
1102 let rem_size = window.rem_size();
1103 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1104
1105 let aside = self.documentation_aside.clone();
1106 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1107 WithRemSize::new(ui_font_size)
1108 .occlude()
1109 .elevation_2(cx)
1110 .w_full()
1111 .p_2()
1112 .overflow_hidden()
1113 .when(is_wide_window, |this| this.max_w_96())
1114 .when(!is_wide_window, |this| this.max_w_48())
1115 .child((aside.render)(cx))
1116 };
1117
1118 let render_menu =
1119 |cx: &mut Context<Self>, window: &mut Window| {
1120 WithRemSize::new(ui_font_size)
1121 .occlude()
1122 .elevation_2(cx)
1123 .flex()
1124 .flex_row()
1125 .flex_shrink_0()
1126 .child(
1127 v_flex()
1128 .id("context-menu")
1129 .max_h(vh(0.75, window))
1130 .flex_shrink_0()
1131 .when_some(self.fixed_width, |this, width| {
1132 this.w(width).overflow_x_hidden()
1133 })
1134 .when(self.fixed_width.is_none(), |this| {
1135 this.min_w(px(200.)).flex_1()
1136 })
1137 .overflow_y_scroll()
1138 .track_focus(&self.focus_handle(cx))
1139 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
1140 this.cancel(&menu::Cancel, window, cx)
1141 }))
1142 .key_context(self.key_context.as_ref())
1143 .on_action(cx.listener(ContextMenu::select_first))
1144 .on_action(cx.listener(ContextMenu::handle_select_last))
1145 .on_action(cx.listener(ContextMenu::select_next))
1146 .on_action(cx.listener(ContextMenu::select_previous))
1147 .on_action(cx.listener(ContextMenu::confirm))
1148 .on_action(cx.listener(ContextMenu::cancel))
1149 .when_some(self.end_slot_action.as_ref(), |el, action| {
1150 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
1151 })
1152 .when(!self.delayed, |mut el| {
1153 for item in self.items.iter() {
1154 if let ContextMenuItem::Entry(ContextMenuEntry {
1155 action: Some(action),
1156 disabled: false,
1157 ..
1158 }) = item
1159 {
1160 el = el.on_boxed_action(
1161 &**action,
1162 cx.listener(ContextMenu::on_action_dispatch),
1163 );
1164 }
1165 }
1166 el
1167 })
1168 .child(
1169 List::new().children(
1170 self.items.iter().enumerate().map(|(ix, item)| {
1171 self.render_menu_item(ix, item, window, cx)
1172 }),
1173 ),
1174 ),
1175 )
1176 };
1177
1178 if is_wide_window {
1179 div()
1180 .relative()
1181 .child(render_menu(cx, window))
1182 .children(aside.map(|(_item_index, aside)| {
1183 h_flex()
1184 .absolute()
1185 .when(aside.side == DocumentationSide::Left, |this| {
1186 this.right_full().mr_1()
1187 })
1188 .when(aside.side == DocumentationSide::Right, |this| {
1189 this.left_full().ml_1()
1190 })
1191 .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
1192 .when(aside.edge == DocumentationEdge::Bottom, |this| {
1193 this.bottom_0()
1194 })
1195 .child(render_aside(aside, cx))
1196 }))
1197 } else {
1198 v_flex()
1199 .w_full()
1200 .gap_1()
1201 .justify_end()
1202 .children(aside.map(|(_, aside)| render_aside(aside, cx)))
1203 .child(render_menu(cx, window))
1204 }
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use gpui::TestAppContext;
1211
1212 use super::*;
1213
1214 #[gpui::test]
1215 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
1216 let cx = cx.add_empty_window();
1217 let context_menu = cx.update(|window, cx| {
1218 ContextMenu::build(window, cx, |menu, _, _| {
1219 menu.header("First header")
1220 .separator()
1221 .entry("First entry", None, |_, _| {})
1222 .separator()
1223 .separator()
1224 .entry("Last entry", None, |_, _| {})
1225 .header("Last header")
1226 })
1227 });
1228
1229 context_menu.update_in(cx, |context_menu, window, cx| {
1230 assert_eq!(
1231 None, context_menu.selected_index,
1232 "No selection is in the menu initially"
1233 );
1234
1235 context_menu.select_first(&SelectFirst, window, cx);
1236 assert_eq!(
1237 Some(2),
1238 context_menu.selected_index,
1239 "Should select first selectable entry, skipping the header and the separator"
1240 );
1241
1242 context_menu.select_next(&SelectNext, window, cx);
1243 assert_eq!(
1244 Some(5),
1245 context_menu.selected_index,
1246 "Should select next selectable entry, skipping 2 separators along the way"
1247 );
1248
1249 context_menu.select_next(&SelectNext, window, cx);
1250 assert_eq!(
1251 Some(2),
1252 context_menu.selected_index,
1253 "Should wrap around to first selectable entry"
1254 );
1255 });
1256
1257 context_menu.update_in(cx, |context_menu, window, cx| {
1258 assert_eq!(
1259 Some(2),
1260 context_menu.selected_index,
1261 "Should start from the first selectable entry"
1262 );
1263
1264 context_menu.select_previous(&SelectPrevious, window, cx);
1265 assert_eq!(
1266 Some(5),
1267 context_menu.selected_index,
1268 "Should wrap around to previous selectable entry (last)"
1269 );
1270
1271 context_menu.select_previous(&SelectPrevious, window, cx);
1272 assert_eq!(
1273 Some(2),
1274 context_menu.selected_index,
1275 "Should go back to previous selectable entry (first)"
1276 );
1277 });
1278
1279 context_menu.update_in(cx, |context_menu, window, cx| {
1280 context_menu.select_first(&SelectFirst, window, cx);
1281 assert_eq!(
1282 Some(2),
1283 context_menu.selected_index,
1284 "Should start from the first selectable entry"
1285 );
1286
1287 context_menu.select_previous(&SelectPrevious, window, cx);
1288 assert_eq!(
1289 Some(5),
1290 context_menu.selected_index,
1291 "Should wrap around to last selectable entry"
1292 );
1293 context_menu.select_next(&SelectNext, window, cx);
1294 assert_eq!(
1295 Some(2),
1296 context_menu.selected_index,
1297 "Should wrap around to first selectable entry"
1298 );
1299 });
1300 }
1301}