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