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