context_menu.rs

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