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