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 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 .map(|focus| {
976 KeyBinding::for_action_in(&**action, focus, window, cx)
977 })
978 .unwrap_or_else(|| {
979 KeyBinding::for_action(&**action, window, cx)
980 })
981 .map(|binding| {
982 div().ml_4().child(binding.disabled(*disabled)).when(
983 *disabled && documentation_aside.is_some(),
984 |parent| parent.invisible(),
985 )
986 })
987 }))
988 .when(*disabled && documentation_aside.is_some(), |parent| {
989 parent.child(
990 Icon::new(IconName::Info)
991 .size(IconSize::XSmall)
992 .color(Color::Muted),
993 )
994 }),
995 )
996 .when_some(
997 end_slot_icon
998 .as_ref()
999 .zip(self.end_slot_action.as_ref())
1000 .zip(end_slot_title.as_ref())
1001 .zip(end_slot_handler.as_ref()),
1002 |el, (((icon, action), title), handler)| {
1003 el.end_slot({
1004 let icon_button = IconButton::new("end-slot-icon", *icon)
1005 .shape(IconButtonShape::Square)
1006 .tooltip({
1007 let action_context = self.action_context.clone();
1008 let title = title.clone();
1009 let action = action.boxed_clone();
1010 move |window, cx| {
1011 action_context
1012 .as_ref()
1013 .map(|focus| {
1014 Tooltip::for_action_in(
1015 title.clone(),
1016 &*action,
1017 focus,
1018 window,
1019 cx,
1020 )
1021 })
1022 .unwrap_or_else(|| {
1023 Tooltip::for_action(
1024 title.clone(),
1025 &*action,
1026 window,
1027 cx,
1028 )
1029 })
1030 }
1031 })
1032 .on_click({
1033 let handler = handler.clone();
1034 move |_, window, cx| {
1035 handler(None, window, cx);
1036 this.update(cx, |this, cx| {
1037 this.rebuild(window, cx);
1038 cx.notify();
1039 })
1040 .ok();
1041 }
1042 });
1043
1044 if *show_end_slot_on_hover {
1045 div()
1046 .visible_on_hover("label_container")
1047 .child(icon_button)
1048 .into_any_element()
1049 } else {
1050 icon_button.into_any_element()
1051 }
1052 })
1053 },
1054 )
1055 .on_click({
1056 let context = self.action_context.clone();
1057 let keep_open_on_confirm = self.keep_open_on_confirm;
1058 move |_, window, cx| {
1059 handler(context.as_ref(), window, cx);
1060 menu.update(cx, |menu, cx| {
1061 menu.clicked = true;
1062 if keep_open_on_confirm {
1063 menu.rebuild(window, cx);
1064 } else {
1065 cx.emit(DismissEvent);
1066 }
1067 })
1068 .ok();
1069 }
1070 }),
1071 )
1072 .into_any_element()
1073 }
1074}
1075
1076impl ContextMenuItem {
1077 fn is_selectable(&self) -> bool {
1078 match self {
1079 ContextMenuItem::Header(_)
1080 | ContextMenuItem::HeaderWithLink(_, _, _)
1081 | ContextMenuItem::Separator
1082 | ContextMenuItem::Label { .. } => false,
1083 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1084 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1085 }
1086 }
1087}
1088
1089impl Render for ContextMenu {
1090 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1091 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1092 let window_size = window.viewport_size();
1093 let rem_size = window.rem_size();
1094 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1095
1096 let aside = self.documentation_aside.clone();
1097 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1098 WithRemSize::new(ui_font_size)
1099 .occlude()
1100 .elevation_2(cx)
1101 .p_2()
1102 .overflow_hidden()
1103 .when(is_wide_window, |this| this.max_w_96())
1104 .when(!is_wide_window, |this| this.max_w_48())
1105 .child((aside.render)(cx))
1106 };
1107
1108 h_flex()
1109 .when(is_wide_window, |this| this.flex_row())
1110 .when(!is_wide_window, |this| this.flex_col())
1111 .w_full()
1112 .map(|div| {
1113 if self.align_popover_top {
1114 div.items_start()
1115 } else {
1116 div.items_end()
1117 }
1118 })
1119 .gap_1()
1120 .child(div().children(aside.clone().and_then(|(_, aside)| {
1121 (aside.side == DocumentationSide::Left).then(|| render_aside(aside, cx))
1122 })))
1123 .child(
1124 WithRemSize::new(ui_font_size)
1125 .occlude()
1126 .elevation_2(cx)
1127 .flex()
1128 .flex_row()
1129 .child(
1130 v_flex()
1131 .id("context-menu")
1132 .max_h(vh(0.75, window))
1133 .when_some(self.fixed_width, |this, width| {
1134 this.w(width).overflow_x_hidden()
1135 })
1136 .when(self.fixed_width.is_none(), |this| {
1137 this.min_w(px(200.)).flex_1()
1138 })
1139 .overflow_y_scroll()
1140 .track_focus(&self.focus_handle(cx))
1141 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
1142 this.cancel(&menu::Cancel, window, cx)
1143 }))
1144 .key_context(self.key_context.as_ref())
1145 .on_action(cx.listener(ContextMenu::select_first))
1146 .on_action(cx.listener(ContextMenu::handle_select_last))
1147 .on_action(cx.listener(ContextMenu::select_next))
1148 .on_action(cx.listener(ContextMenu::select_previous))
1149 .on_action(cx.listener(ContextMenu::confirm))
1150 .on_action(cx.listener(ContextMenu::cancel))
1151 .when_some(self.end_slot_action.as_ref(), |el, action| {
1152 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
1153 })
1154 .when(!self.delayed, |mut el| {
1155 for item in self.items.iter() {
1156 if let ContextMenuItem::Entry(ContextMenuEntry {
1157 action: Some(action),
1158 disabled: false,
1159 ..
1160 }) = item
1161 {
1162 el = el.on_boxed_action(
1163 &**action,
1164 cx.listener(ContextMenu::on_action_dispatch),
1165 );
1166 }
1167 }
1168 el
1169 })
1170 .child(
1171 List::new().children(
1172 self.items.iter().enumerate().map(|(ix, item)| {
1173 self.render_menu_item(ix, item, window, cx)
1174 }),
1175 ),
1176 ),
1177 ),
1178 )
1179 .child(div().children(aside.and_then(|(_, aside)| {
1180 (aside.side == DocumentationSide::Right).then(|| render_aside(aside, cx))
1181 })))
1182 }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use gpui::TestAppContext;
1188
1189 use super::*;
1190
1191 #[gpui::test]
1192 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
1193 let cx = cx.add_empty_window();
1194 let context_menu = cx.update(|window, cx| {
1195 ContextMenu::build(window, cx, |menu, _, _| {
1196 menu.header("First header")
1197 .separator()
1198 .entry("First entry", None, |_, _| {})
1199 .separator()
1200 .separator()
1201 .entry("Last entry", None, |_, _| {})
1202 })
1203 });
1204
1205 context_menu.update_in(cx, |context_menu, window, cx| {
1206 assert_eq!(
1207 None, context_menu.selected_index,
1208 "No selection is in the menu initially"
1209 );
1210
1211 context_menu.select_first(&SelectFirst, window, cx);
1212 assert_eq!(
1213 Some(2),
1214 context_menu.selected_index,
1215 "Should select first selectable entry, skipping the header and the separator"
1216 );
1217
1218 context_menu.select_next(&SelectNext, window, cx);
1219 assert_eq!(
1220 Some(5),
1221 context_menu.selected_index,
1222 "Should select next selectable entry, skipping 2 separators along the way"
1223 );
1224
1225 context_menu.select_next(&SelectNext, window, cx);
1226 assert_eq!(
1227 Some(2),
1228 context_menu.selected_index,
1229 "Should wrap around to first selectable entry"
1230 );
1231 });
1232
1233 context_menu.update_in(cx, |context_menu, window, cx| {
1234 assert_eq!(
1235 Some(2),
1236 context_menu.selected_index,
1237 "Should start from the first selectable entry"
1238 );
1239
1240 context_menu.select_previous(&SelectPrevious, window, cx);
1241 assert_eq!(
1242 Some(5),
1243 context_menu.selected_index,
1244 "Should wrap around to previous selectable entry (last)"
1245 );
1246
1247 context_menu.select_previous(&SelectPrevious, window, cx);
1248 assert_eq!(
1249 Some(2),
1250 context_menu.selected_index,
1251 "Should go back to previous selectable entry (first)"
1252 );
1253 });
1254 }
1255}