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