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