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