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