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