1#![allow(missing_docs)]
2use crate::{
3 h_flex, prelude::*, utils::WithRemSize, v_flex, Icon, IconName, IconSize, KeyBinding, Label,
4 List, ListItem, ListSeparator, ListSubHeader,
5};
6use gpui::{
7 px, Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle,
8 Focusable, IntoElement, Render, Subscription,
9};
10use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
11use settings::Settings;
12use std::{rc::Rc, time::Duration};
13use theme::ThemeSettings;
14
15pub enum ContextMenuItem {
16 Separator,
17 Header(SharedString),
18 Label(SharedString),
19 Entry(ContextMenuEntry),
20 CustomEntry {
21 entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
22 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
23 selectable: bool,
24 },
25}
26
27impl ContextMenuItem {
28 pub fn custom_entry(
29 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
30 handler: impl Fn(&mut Window, &mut App) + 'static,
31 ) -> Self {
32 Self::CustomEntry {
33 entry_render: Box::new(entry_render),
34 handler: Rc::new(move |_, window, cx| handler(window, cx)),
35 selectable: true,
36 }
37 }
38}
39
40pub struct ContextMenuEntry {
41 toggle: Option<(IconPosition, bool)>,
42 label: SharedString,
43 icon: Option<IconName>,
44 icon_position: IconPosition,
45 icon_size: IconSize,
46 icon_color: Option<Color>,
47 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
48 action: Option<Box<dyn Action>>,
49 disabled: bool,
50 documentation_aside: Option<Rc<dyn Fn(&mut App) -> AnyElement>>,
51}
52
53impl ContextMenuEntry {
54 pub fn new(label: impl Into<SharedString>) -> Self {
55 ContextMenuEntry {
56 toggle: None,
57 label: label.into(),
58 icon: None,
59 icon_position: IconPosition::Start,
60 icon_size: IconSize::Small,
61 icon_color: None,
62 handler: Rc::new(|_, _, _| {}),
63 action: None,
64 disabled: false,
65 documentation_aside: None,
66 }
67 }
68
69 pub fn toggleable(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
70 self.toggle = Some((toggle_position, toggled));
71 self
72 }
73
74 pub fn icon(mut self, icon: IconName) -> Self {
75 self.icon = Some(icon);
76 self
77 }
78
79 pub fn icon_position(mut self, position: IconPosition) -> Self {
80 self.icon_position = position;
81 self
82 }
83
84 pub fn icon_size(mut self, icon_size: IconSize) -> Self {
85 self.icon_size = icon_size;
86 self
87 }
88
89 pub fn icon_color(mut self, icon_color: Color) -> Self {
90 self.icon_color = Some(icon_color);
91 self
92 }
93
94 pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
95 self.toggle = Some((toggle_position, toggled));
96 self
97 }
98
99 pub fn action(mut self, action: Option<Box<dyn Action>>) -> Self {
100 self.action = action;
101 self
102 }
103
104 pub fn handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
105 self.handler = Rc::new(move |_, window, cx| handler(window, cx));
106 self
107 }
108
109 pub fn disabled(mut self, disabled: bool) -> Self {
110 self.disabled = disabled;
111 self
112 }
113
114 pub fn documentation_aside(
115 mut self,
116 element: impl Fn(&mut App) -> AnyElement + 'static,
117 ) -> Self {
118 self.documentation_aside = Some(Rc::new(element));
119 self
120 }
121}
122
123impl From<ContextMenuEntry> for ContextMenuItem {
124 fn from(entry: ContextMenuEntry) -> Self {
125 ContextMenuItem::Entry(entry)
126 }
127}
128
129pub struct ContextMenu {
130 items: Vec<ContextMenuItem>,
131 focus_handle: FocusHandle,
132 action_context: Option<FocusHandle>,
133 selected_index: Option<usize>,
134 delayed: bool,
135 clicked: bool,
136 _on_blur_subscription: Subscription,
137 keep_open_on_confirm: bool,
138 documentation_aside: Option<(usize, Rc<dyn Fn(&mut App) -> AnyElement>)>,
139}
140
141impl Focusable for ContextMenu {
142 fn focus_handle(&self, _cx: &App) -> FocusHandle {
143 self.focus_handle.clone()
144 }
145}
146
147impl EventEmitter<DismissEvent> for ContextMenu {}
148
149impl FluentBuilder for ContextMenu {}
150
151impl ContextMenu {
152 pub fn build(
153 window: &mut Window,
154 cx: &mut App,
155 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
156 ) -> Entity<Self> {
157 cx.new(|cx| {
158 let focus_handle = cx.focus_handle();
159 let _on_blur_subscription = cx.on_blur(
160 &focus_handle,
161 window,
162 |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
163 );
164 window.refresh();
165 f(
166 Self {
167 items: Default::default(),
168 focus_handle,
169 action_context: None,
170 selected_index: None,
171 delayed: false,
172 clicked: false,
173 _on_blur_subscription,
174 keep_open_on_confirm: false,
175 documentation_aside: None,
176 },
177 window,
178 cx,
179 )
180 })
181 }
182
183 pub fn context(mut self, focus: FocusHandle) -> Self {
184 self.action_context = Some(focus);
185 self
186 }
187
188 pub fn header(mut self, title: impl Into<SharedString>) -> Self {
189 self.items.push(ContextMenuItem::Header(title.into()));
190 self
191 }
192
193 pub fn separator(mut self) -> Self {
194 self.items.push(ContextMenuItem::Separator);
195 self
196 }
197
198 pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
199 self.items.extend(items.into_iter().map(Into::into));
200 self
201 }
202
203 pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
204 self.items.push(item.into());
205 self
206 }
207
208 pub fn entry(
209 mut self,
210 label: impl Into<SharedString>,
211 action: Option<Box<dyn Action>>,
212 handler: impl Fn(&mut Window, &mut App) + 'static,
213 ) -> Self {
214 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
215 toggle: None,
216 label: label.into(),
217 handler: Rc::new(move |_, window, cx| handler(window, cx)),
218 icon: None,
219 icon_position: IconPosition::End,
220 icon_size: IconSize::Small,
221 icon_color: None,
222 action,
223 disabled: false,
224 documentation_aside: None,
225 }));
226 self
227 }
228
229 pub fn toggleable_entry(
230 mut self,
231 label: impl Into<SharedString>,
232 toggled: bool,
233 position: IconPosition,
234 action: Option<Box<dyn Action>>,
235 handler: impl Fn(&mut Window, &mut App) + 'static,
236 ) -> Self {
237 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
238 toggle: Some((position, toggled)),
239 label: label.into(),
240 handler: Rc::new(move |_, window, cx| handler(window, cx)),
241 icon: None,
242 icon_position: position,
243 icon_size: IconSize::Small,
244 icon_color: None,
245 action,
246 disabled: false,
247 documentation_aside: None,
248 }));
249 self
250 }
251
252 pub fn custom_row(
253 mut self,
254 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
255 ) -> Self {
256 self.items.push(ContextMenuItem::CustomEntry {
257 entry_render: Box::new(entry_render),
258 handler: Rc::new(|_, _, _| {}),
259 selectable: false,
260 });
261 self
262 }
263
264 pub fn custom_entry(
265 mut self,
266 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
267 handler: impl Fn(&mut Window, &mut App) + 'static,
268 ) -> Self {
269 self.items.push(ContextMenuItem::CustomEntry {
270 entry_render: Box::new(entry_render),
271 handler: Rc::new(move |_, window, cx| handler(window, cx)),
272 selectable: true,
273 });
274 self
275 }
276
277 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
278 self.items.push(ContextMenuItem::Label(label.into()));
279 self
280 }
281
282 pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
283 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
284 toggle: None,
285 label: label.into(),
286 action: Some(action.boxed_clone()),
287 handler: Rc::new(move |context, window, cx| {
288 if let Some(context) = &context {
289 window.focus(context);
290 }
291 window.dispatch_action(action.boxed_clone(), cx);
292 }),
293 icon: None,
294 icon_position: IconPosition::End,
295 icon_size: IconSize::Small,
296 icon_color: None,
297 disabled: false,
298 documentation_aside: None,
299 }));
300 self
301 }
302
303 pub fn disabled_action(
304 mut self,
305 label: impl Into<SharedString>,
306 action: Box<dyn Action>,
307 ) -> Self {
308 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
309 toggle: None,
310 label: label.into(),
311 action: Some(action.boxed_clone()),
312 handler: Rc::new(move |context, window, cx| {
313 if let Some(context) = &context {
314 window.focus(context);
315 }
316 window.dispatch_action(action.boxed_clone(), cx);
317 }),
318 icon: None,
319 icon_size: IconSize::Small,
320 icon_position: IconPosition::End,
321 icon_color: None,
322 disabled: true,
323 documentation_aside: None,
324 }));
325 self
326 }
327
328 pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
329 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
330 toggle: None,
331 label: label.into(),
332 action: Some(action.boxed_clone()),
333 handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
334 icon: Some(IconName::ArrowUpRight),
335 icon_size: IconSize::XSmall,
336 icon_position: IconPosition::End,
337 icon_color: None,
338 disabled: false,
339 documentation_aside: None,
340 }));
341 self
342 }
343
344 pub fn keep_open_on_confirm(mut self) -> Self {
345 self.keep_open_on_confirm = true;
346 self
347 }
348
349 pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
350 let context = self.action_context.as_ref();
351 if let Some(
352 ContextMenuItem::Entry(ContextMenuEntry {
353 handler,
354 disabled: false,
355 ..
356 })
357 | ContextMenuItem::CustomEntry { handler, .. },
358 ) = self.selected_index.and_then(|ix| self.items.get(ix))
359 {
360 (handler)(context, window, cx)
361 }
362
363 if !self.keep_open_on_confirm {
364 cx.emit(DismissEvent);
365 }
366 }
367
368 pub fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
369 cx.emit(DismissEvent);
370 cx.emit(DismissEvent);
371 }
372
373 fn select_first(&mut self, _: &SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
374 if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
375 self.select_index(ix);
376 }
377 cx.notify();
378 }
379
380 pub fn select_last(&mut self) -> Option<usize> {
381 for (ix, item) in self.items.iter().enumerate().rev() {
382 if item.is_selectable() {
383 return self.select_index(ix);
384 }
385 }
386 None
387 }
388
389 fn handle_select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
390 if self.select_last().is_some() {
391 cx.notify();
392 }
393 }
394
395 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
396 if let Some(ix) = self.selected_index {
397 let next_index = ix + 1;
398 if self.items.len() <= next_index {
399 self.select_first(&SelectFirst, window, cx);
400 } else {
401 for (ix, item) in self.items.iter().enumerate().skip(next_index) {
402 if item.is_selectable() {
403 self.select_index(ix);
404 cx.notify();
405 break;
406 }
407 }
408 }
409 } else {
410 self.select_first(&SelectFirst, window, cx);
411 }
412 }
413
414 pub fn select_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
415 if let Some(ix) = self.selected_index {
416 if ix == 0 {
417 self.handle_select_last(&SelectLast, window, cx);
418 } else {
419 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
420 if item.is_selectable() {
421 self.select_index(ix);
422 cx.notify();
423 break;
424 }
425 }
426 }
427 } else {
428 self.handle_select_last(&SelectLast, window, cx);
429 }
430 }
431
432 fn select_index(&mut self, ix: usize) -> Option<usize> {
433 self.documentation_aside = None;
434 let item = self.items.get(ix)?;
435 if item.is_selectable() {
436 self.selected_index = Some(ix);
437 if let ContextMenuItem::Entry(entry) = item {
438 if let Some(callback) = &entry.documentation_aside {
439 self.documentation_aside = Some((ix, callback.clone()));
440 }
441 }
442 }
443 Some(ix)
444 }
445
446 pub fn on_action_dispatch(
447 &mut self,
448 dispatched: &dyn Action,
449 window: &mut Window,
450 cx: &mut Context<Self>,
451 ) {
452 if self.clicked {
453 cx.propagate();
454 return;
455 }
456
457 if let Some(ix) = self.items.iter().position(|item| {
458 if let ContextMenuItem::Entry(ContextMenuEntry {
459 action: Some(action),
460 disabled: false,
461 ..
462 }) = item
463 {
464 action.partial_eq(dispatched)
465 } else {
466 false
467 }
468 }) {
469 self.select_index(ix);
470 self.delayed = true;
471 cx.notify();
472 let action = dispatched.boxed_clone();
473 cx.spawn_in(window, |this, mut cx| async move {
474 cx.background_executor()
475 .timer(Duration::from_millis(50))
476 .await;
477 cx.update(|window, cx| {
478 this.update(cx, |this, cx| {
479 this.cancel(&menu::Cancel, window, cx);
480 window.dispatch_action(action, cx);
481 })
482 })
483 })
484 .detach_and_log_err(cx);
485 } else {
486 cx.propagate()
487 }
488 }
489
490 pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
491 self._on_blur_subscription = new_subscription;
492 self
493 }
494}
495
496impl ContextMenuItem {
497 fn is_selectable(&self) -> bool {
498 match self {
499 ContextMenuItem::Header(_)
500 | ContextMenuItem::Separator
501 | ContextMenuItem::Label { .. } => false,
502 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
503 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
504 }
505 }
506}
507
508impl Render for ContextMenu {
509 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
510 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
511
512 let aside = self
513 .documentation_aside
514 .as_ref()
515 .map(|(_, callback)| callback.clone());
516
517 h_flex()
518 .w_full()
519 .items_start()
520 .gap_1()
521 .when_some(aside, |this, aside| {
522 this.child(
523 WithRemSize::new(ui_font_size)
524 .occlude()
525 .elevation_2(cx)
526 .p_2()
527 .max_w_96()
528 .child(aside(cx)),
529 )
530 })
531 .child(
532 WithRemSize::new(ui_font_size)
533 .occlude()
534 .elevation_2(cx)
535 .flex()
536 .flex_row()
537 .child(
538 v_flex()
539 .id("context-menu")
540 .min_w(px(200.))
541 .max_h(vh(0.75, window))
542 .flex_1()
543 .overflow_y_scroll()
544 .track_focus(&self.focus_handle(cx))
545 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
546 this.cancel(&menu::Cancel, window, cx)
547 }))
548 .key_context("menu")
549 .on_action(cx.listener(ContextMenu::select_first))
550 .on_action(cx.listener(ContextMenu::handle_select_last))
551 .on_action(cx.listener(ContextMenu::select_next))
552 .on_action(cx.listener(ContextMenu::select_prev))
553 .on_action(cx.listener(ContextMenu::confirm))
554 .on_action(cx.listener(ContextMenu::cancel))
555 .when(!self.delayed, |mut el| {
556 for item in self.items.iter() {
557 if let ContextMenuItem::Entry(ContextMenuEntry {
558 action: Some(action),
559 disabled: false,
560 ..
561 }) = item
562 {
563 el = el.on_boxed_action(
564 &**action,
565 cx.listener(ContextMenu::on_action_dispatch),
566 );
567 }
568 }
569 el
570 })
571 .child(List::new().children(self.items.iter_mut().enumerate().map(
572 |(ix, item)| {
573 match item {
574 ContextMenuItem::Separator => {
575 ListSeparator.into_any_element()
576 }
577 ContextMenuItem::Header(header) => {
578 ListSubHeader::new(header.clone())
579 .inset(true)
580 .into_any_element()
581 }
582 ContextMenuItem::Label(label) => ListItem::new(ix)
583 .inset(true)
584 .disabled(true)
585 .child(Label::new(label.clone()))
586 .into_any_element(),
587 ContextMenuItem::Entry(ContextMenuEntry {
588 toggle,
589 label,
590 handler,
591 icon,
592 icon_position,
593 icon_size,
594 icon_color,
595 action,
596 disabled,
597 documentation_aside,
598 }) => {
599 let handler = handler.clone();
600 let menu = cx.entity().downgrade();
601
602 let icon_color = if *disabled {
603 Color::Muted
604 } else if toggle.is_some() {
605 icon_color.unwrap_or(Color::Accent)
606 } else {
607 icon_color.unwrap_or(Color::Default)
608 };
609
610 let label_color = if *disabled {
611 Color::Muted
612 } else {
613 Color::Default
614 };
615
616 let label_element = if let Some(icon_name) = icon {
617 h_flex()
618 .gap_1p5()
619 .when(
620 *icon_position == IconPosition::Start && toggle.is_none(),
621 |flex| {
622 flex.child(
623 Icon::new(*icon_name)
624 .size(*icon_size)
625 .color(icon_color),
626 )
627 },
628 )
629 .child(
630 Label::new(label.clone())
631 .color(label_color),
632 )
633 .when(
634 *icon_position == IconPosition::End,
635 |flex| {
636 flex.child(
637 Icon::new(*icon_name)
638 .size(*icon_size)
639 .color(icon_color),
640 )
641 },
642 )
643 .into_any_element()
644 } else {
645 Label::new(label.clone())
646 .color(label_color)
647 .into_any_element()
648 };
649
650 let documentation_aside_callback =
651 documentation_aside.clone();
652
653 div()
654 .id(("context-menu-child", ix))
655 .when_some(
656 documentation_aside_callback,
657 |this, documentation_aside_callback| {
658 this.occlude().on_hover(cx.listener(
659 move |menu, hovered, _, cx| {
660 if *hovered {
661 menu.documentation_aside = Some((ix, documentation_aside_callback.clone()));
662 cx.notify();
663 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
664 menu.documentation_aside = None;
665 cx.notify();
666 }
667 },
668 ))
669 },
670 )
671 .child(
672 ListItem::new(ix)
673 .inset(true)
674 .disabled(*disabled)
675 .toggle_state(
676 Some(ix) == self.selected_index,
677 )
678 .when_some(
679 *toggle,
680 |list_item, (position, toggled)| {
681 let contents =
682 div().flex_none().child(
683 Icon::new(icon.unwrap_or(IconName::Check))
684 .color(icon_color)
685 .size(*icon_size)
686 )
687 .when(!toggled, |contents|
688 contents.invisible()
689 );
690
691 match position {
692 IconPosition::Start => {
693 list_item
694 .start_slot(contents)
695 }
696 IconPosition::End => {
697 list_item.end_slot(contents)
698 }
699 }
700 },
701 )
702 .child(
703 h_flex()
704 .w_full()
705 .justify_between()
706 .child(label_element)
707 .debug_selector(|| {
708 format!("MENU_ITEM-{}", label)
709 })
710 .children(
711 action.as_ref().and_then(
712 |action| {
713 self.action_context
714 .as_ref()
715 .map(|focus| {
716 KeyBinding::for_action_in(
717 &**action, focus,
718 window,
719 cx
720 )
721 })
722 .unwrap_or_else(|| {
723 KeyBinding::for_action(
724 &**action, window, cx
725 )
726 })
727 .map(|binding| {
728 div().ml_4().child(binding)
729 })
730 },
731 ),
732 ),
733 )
734 .on_click({
735 let context =
736 self.action_context.clone();
737 move |_, window, cx| {
738 handler(
739 context.as_ref(),
740 window,
741 cx,
742 );
743 menu.update(cx, |menu, cx| {
744 menu.clicked = true;
745 cx.emit(DismissEvent);
746 })
747 .ok();
748 }
749 }),
750 )
751 .into_any_element()
752 }
753 ContextMenuItem::CustomEntry {
754 entry_render,
755 handler,
756 selectable,
757 } => {
758 let handler = handler.clone();
759 let menu = cx.entity().downgrade();
760 let selectable = *selectable;
761 ListItem::new(ix)
762 .inset(true)
763 .toggle_state(if selectable {
764 Some(ix) == self.selected_index
765 } else {
766 false
767 })
768 .selectable(selectable)
769 .when(selectable, |item| {
770 item.on_click({
771 let context = self.action_context.clone();
772 move |_, window, cx| {
773 handler(context.as_ref(), window, cx);
774 menu.update(cx, |menu, cx| {
775 menu.clicked = true;
776 cx.emit(DismissEvent);
777 })
778 .ok();
779 }
780 })
781 })
782 .child(entry_render(window, cx))
783 .into_any_element()
784 }
785 }
786 },
787 )))
788 ),
789 )
790 }
791}