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 let next_index = ix + 1;
266 if self.items.len() <= next_index {
267 self.select_first(&SelectFirst, cx);
268 } else {
269 for (ix, item) in self.items.iter().enumerate().skip(next_index) {
270 if item.is_selectable() {
271 self.selected_index = Some(ix);
272 cx.notify();
273 break;
274 }
275 }
276 }
277 } else {
278 self.select_first(&SelectFirst, cx);
279 }
280 }
281
282 pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
283 if let Some(ix) = self.selected_index {
284 if ix == 0 {
285 self.handle_select_last(&SelectLast, cx);
286 } else {
287 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
288 if item.is_selectable() {
289 self.selected_index = Some(ix);
290 cx.notify();
291 break;
292 }
293 }
294 }
295 } else {
296 self.handle_select_last(&SelectLast, cx);
297 }
298 }
299
300 pub fn on_action_dispatch(&mut self, dispatched: &dyn Action, cx: &mut ViewContext<Self>) {
301 if self.clicked {
302 cx.propagate();
303 return;
304 }
305
306 if let Some(ix) = self.items.iter().position(|item| {
307 if let ContextMenuItem::Entry {
308 action: Some(action),
309 disabled: false,
310 ..
311 } = item
312 {
313 action.partial_eq(dispatched)
314 } else {
315 false
316 }
317 }) {
318 self.selected_index = Some(ix);
319 self.delayed = true;
320 cx.notify();
321 let action = dispatched.boxed_clone();
322 cx.spawn(|this, mut cx| async move {
323 cx.background_executor()
324 .timer(Duration::from_millis(50))
325 .await;
326 this.update(&mut cx, |this, cx| {
327 this.cancel(&menu::Cancel, cx);
328 cx.dispatch_action(action);
329 })
330 })
331 .detach_and_log_err(cx);
332 } else {
333 cx.propagate()
334 }
335 }
336
337 pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
338 self._on_blur_subscription = new_subscription;
339 self
340 }
341}
342
343impl ContextMenuItem {
344 fn is_selectable(&self) -> bool {
345 match self {
346 ContextMenuItem::Header(_)
347 | ContextMenuItem::Separator
348 | ContextMenuItem::Label { .. } => false,
349 ContextMenuItem::Entry { disabled, .. } => !disabled,
350 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
351 }
352 }
353}
354
355impl Render for ContextMenu {
356 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
357 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
358
359 div().occlude().elevation_2(cx).flex().flex_row().child(
360 WithRemSize::new(ui_font_size).flex().child(
361 v_flex()
362 .id("context-menu")
363 .min_w(px(200.))
364 .max_h(vh(0.75, cx))
365 .overflow_y_scroll()
366 .track_focus(&self.focus_handle(cx))
367 .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
368 .key_context("menu")
369 .on_action(cx.listener(ContextMenu::select_first))
370 .on_action(cx.listener(ContextMenu::handle_select_last))
371 .on_action(cx.listener(ContextMenu::select_next))
372 .on_action(cx.listener(ContextMenu::select_prev))
373 .on_action(cx.listener(ContextMenu::confirm))
374 .on_action(cx.listener(ContextMenu::cancel))
375 .when(!self.delayed, |mut el| {
376 for item in self.items.iter() {
377 if let ContextMenuItem::Entry {
378 action: Some(action),
379 disabled: false,
380 ..
381 } = item
382 {
383 el = el.on_boxed_action(
384 &**action,
385 cx.listener(ContextMenu::on_action_dispatch),
386 );
387 }
388 }
389 el
390 })
391 .flex_none()
392 .child(List::new().children(self.items.iter_mut().enumerate().map(
393 |(ix, item)| {
394 match item {
395 ContextMenuItem::Separator => ListSeparator.into_any_element(),
396 ContextMenuItem::Header(header) => {
397 ListSubHeader::new(header.clone())
398 .inset(true)
399 .into_any_element()
400 }
401 ContextMenuItem::Label(label) => ListItem::new(ix)
402 .inset(true)
403 .disabled(true)
404 .child(Label::new(label.clone()))
405 .into_any_element(),
406 ContextMenuItem::Entry {
407 toggle,
408 label,
409 handler,
410 icon,
411 icon_size,
412 action,
413 disabled,
414 } => {
415 let handler = handler.clone();
416 let menu = cx.view().downgrade();
417 let color = if *disabled {
418 Color::Muted
419 } else {
420 Color::Default
421 };
422 let label_element = if let Some(icon_name) = icon {
423 h_flex()
424 .gap_1()
425 .child(Label::new(label.clone()).color(color))
426 .child(
427 Icon::new(*icon_name).size(*icon_size).color(color),
428 )
429 .into_any_element()
430 } else {
431 Label::new(label.clone()).color(color).into_any_element()
432 };
433
434 ListItem::new(ix)
435 .inset(true)
436 .disabled(*disabled)
437 .selected(Some(ix) == self.selected_index)
438 .when_some(*toggle, |list_item, (position, toggled)| {
439 let contents = if toggled {
440 v_flex().flex_none().child(
441 Icon::new(IconName::Check).color(Color::Accent),
442 )
443 } else {
444 v_flex()
445 .flex_none()
446 .size(IconSize::default().rems())
447 };
448 match position {
449 IconPosition::Start => {
450 list_item.start_slot(contents)
451 }
452 IconPosition::End => list_item.end_slot(contents),
453 }
454 })
455 .child(
456 h_flex()
457 .w_full()
458 .justify_between()
459 .child(label_element)
460 .debug_selector(|| format!("MENU_ITEM-{}", label))
461 .children(action.as_ref().and_then(|action| {
462 self.action_context
463 .as_ref()
464 .map(|focus| {
465 KeyBinding::for_action_in(
466 &**action, focus, cx,
467 )
468 })
469 .unwrap_or_else(|| {
470 KeyBinding::for_action(&**action, cx)
471 })
472 .map(|binding| div().ml_4().child(binding))
473 })),
474 )
475 .on_click({
476 let context = self.action_context.clone();
477 move |_, cx| {
478 handler(context.as_ref(), cx);
479 menu.update(cx, |menu, cx| {
480 menu.clicked = true;
481 cx.emit(DismissEvent);
482 })
483 .ok();
484 }
485 })
486 .into_any_element()
487 }
488 ContextMenuItem::CustomEntry {
489 entry_render,
490 handler,
491 selectable,
492 } => {
493 let handler = handler.clone();
494 let menu = cx.view().downgrade();
495 let selectable = *selectable;
496 ListItem::new(ix)
497 .inset(true)
498 .selected(if selectable {
499 Some(ix) == self.selected_index
500 } else {
501 false
502 })
503 .selectable(selectable)
504 .when(selectable, |item| {
505 item.on_click({
506 let context = self.action_context.clone();
507 move |_, cx| {
508 handler(context.as_ref(), cx);
509 menu.update(cx, |menu, cx| {
510 menu.clicked = true;
511 cx.emit(DismissEvent);
512 })
513 .ok();
514 }
515 })
516 })
517 .child(entry_render(cx))
518 .into_any_element()
519 }
520 }
521 },
522 ))),
523 ),
524 )
525 }
526}