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