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