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