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