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