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