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_80()
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 let icon_color = if *disabled {
602 Color::Muted
603 } else {
604 icon_color.unwrap_or(Color::Default)
605 };
606 let label_color = if *disabled {
607 Color::Muted
608 } else {
609 Color::Default
610 };
611 let label_element = if let Some(icon_name) = icon {
612 h_flex()
613 .gap_1p5()
614 .when(
615 *icon_position == IconPosition::Start,
616 |flex| {
617 flex.child(
618 Icon::new(*icon_name)
619 .size(*icon_size)
620 .color(icon_color),
621 )
622 },
623 )
624 .child(
625 Label::new(label.clone())
626 .color(label_color),
627 )
628 .when(
629 *icon_position == IconPosition::End,
630 |flex| {
631 flex.child(
632 Icon::new(*icon_name)
633 .size(*icon_size)
634 .color(icon_color),
635 )
636 },
637 )
638 .into_any_element()
639 } else {
640 Label::new(label.clone())
641 .color(label_color)
642 .into_any_element()
643 };
644 let documentation_aside_callback =
645 documentation_aside.clone();
646 div()
647 .id(("context-menu-child", ix))
648 .when_some(
649 documentation_aside_callback,
650 |this, documentation_aside_callback| {
651 this.occlude().on_hover(cx.listener(
652 move |menu, hovered, _, cx| {
653 if *hovered {
654 menu.documentation_aside = Some((ix, documentation_aside_callback.clone()));
655 cx.notify();
656 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
657 menu.documentation_aside = None;
658 cx.notify();
659 }
660 },
661 ))
662 },
663 )
664 .child(
665 ListItem::new(ix)
666 .inset(true)
667 .disabled(*disabled)
668 .toggle_state(
669 Some(ix) == self.selected_index,
670 )
671 .when_some(
672 *toggle,
673 |list_item, (position, toggled)| {
674 let contents = if toggled {
675 v_flex().flex_none().child(
676 Icon::new(IconName::Check)
677 .color(Color::Accent),
678 )
679 } else {
680 v_flex().flex_none().size(
681 IconSize::default().rems(),
682 )
683 };
684 match position {
685 IconPosition::Start => {
686 list_item
687 .start_slot(contents)
688 }
689 IconPosition::End => {
690 list_item.end_slot(contents)
691 }
692 }
693 },
694 )
695 .child(
696 h_flex()
697 .w_full()
698 .justify_between()
699 .child(label_element)
700 .debug_selector(|| {
701 format!("MENU_ITEM-{}", label)
702 })
703 .children(
704 action.as_ref().and_then(
705 |action| {
706 self.action_context
707 .as_ref()
708 .map(|focus| {
709 KeyBinding::for_action_in(
710 &**action, focus,
711 window,
712 )
713 })
714 .unwrap_or_else(|| {
715 KeyBinding::for_action(
716 &**action, window,
717 )
718 })
719 .map(|binding| {
720 div().ml_4().child(binding)
721 })
722 },
723 ),
724 ),
725 )
726 .on_click({
727 let context =
728 self.action_context.clone();
729 move |_, window, cx| {
730 handler(
731 context.as_ref(),
732 window,
733 cx,
734 );
735 menu.update(cx, |menu, cx| {
736 menu.clicked = true;
737 cx.emit(DismissEvent);
738 })
739 .ok();
740 }
741 }),
742 )
743 .into_any_element()
744 }
745 ContextMenuItem::CustomEntry {
746 entry_render,
747 handler,
748 selectable,
749 } => {
750 let handler = handler.clone();
751 let menu = cx.entity().downgrade();
752 let selectable = *selectable;
753 ListItem::new(ix)
754 .inset(true)
755 .toggle_state(if selectable {
756 Some(ix) == self.selected_index
757 } else {
758 false
759 })
760 .selectable(selectable)
761 .when(selectable, |item| {
762 item.on_click({
763 let context = self.action_context.clone();
764 move |_, window, cx| {
765 handler(context.as_ref(), window, cx);
766 menu.update(cx, |menu, cx| {
767 menu.clicked = true;
768 cx.emit(DismissEvent);
769 })
770 .ok();
771 }
772 })
773 })
774 .child(entry_render(window, cx))
775 .into_any_element()
776 }
777 }
778 },
779 ))),
780 ),
781 )
782 }
783}