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