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