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