context_menu.rs

  1use gpui::{
  2    anyhow,
  3    elements::*,
  4    geometry::vector::Vector2F,
  5    impl_internal_actions,
  6    keymap_matcher::KeymapContext,
  7    platform::{CursorStyle, MouseButton},
  8    Action, AnyViewHandle, AppContext, Axis, Entity, MouseState, SizeConstraint, Subscription,
  9    View, ViewContext,
 10};
 11use menu::*;
 12use settings::Settings;
 13use std::{any::TypeId, borrow::Cow, time::Duration};
 14
 15#[derive(Copy, Clone, PartialEq)]
 16struct Clicked;
 17
 18impl_internal_actions!(context_menu, [Clicked]);
 19
 20pub fn init(cx: &mut AppContext) {
 21    cx.add_action(ContextMenu::select_first);
 22    cx.add_action(ContextMenu::select_last);
 23    cx.add_action(ContextMenu::select_next);
 24    cx.add_action(ContextMenu::select_prev);
 25    cx.add_action(ContextMenu::clicked);
 26    cx.add_action(ContextMenu::confirm);
 27    cx.add_action(ContextMenu::cancel);
 28}
 29
 30pub type StaticItem = Box<dyn Fn(&mut AppContext) -> AnyElement<ContextMenu>>;
 31
 32type ContextMenuItemBuilder =
 33    Box<dyn Fn(&mut MouseState, &theme::ContextMenuItem) -> AnyElement<ContextMenu>>;
 34
 35pub enum ContextMenuItemLabel {
 36    String(Cow<'static, str>),
 37    Element(ContextMenuItemBuilder),
 38}
 39
 40pub enum ContextMenuItem {
 41    Item {
 42        label: ContextMenuItemLabel,
 43        action: Box<dyn Action>,
 44    },
 45    Static(StaticItem),
 46    Separator,
 47}
 48
 49impl ContextMenuItem {
 50    pub fn element_item(label: ContextMenuItemBuilder, action: impl 'static + Action) -> Self {
 51        Self::Item {
 52            label: ContextMenuItemLabel::Element(label),
 53            action: Box::new(action),
 54        }
 55    }
 56
 57    pub fn item(label: impl Into<Cow<'static, str>>, action: impl 'static + Action) -> Self {
 58        Self::Item {
 59            label: ContextMenuItemLabel::String(label.into()),
 60            action: Box::new(action),
 61        }
 62    }
 63
 64    pub fn separator() -> Self {
 65        Self::Separator
 66    }
 67
 68    fn is_action(&self) -> bool {
 69        matches!(self, Self::Item { .. })
 70    }
 71
 72    fn action_id(&self) -> Option<TypeId> {
 73        match self {
 74            ContextMenuItem::Item { action, .. } => Some(action.id()),
 75            ContextMenuItem::Static(..) | ContextMenuItem::Separator => None,
 76        }
 77    }
 78}
 79
 80pub struct ContextMenu {
 81    show_count: usize,
 82    anchor_position: Vector2F,
 83    anchor_corner: AnchorCorner,
 84    position_mode: OverlayPositionMode,
 85    items: Vec<ContextMenuItem>,
 86    selected_index: Option<usize>,
 87    visible: bool,
 88    previously_focused_view_id: Option<usize>,
 89    clicked: bool,
 90    parent_view_id: usize,
 91    _actions_observation: Subscription,
 92}
 93
 94impl Entity for ContextMenu {
 95    type Event = ();
 96}
 97
 98impl View for ContextMenu {
 99    fn ui_name() -> &'static str {
100        "ContextMenu"
101    }
102
103    fn keymap_context(&self, _: &AppContext) -> KeymapContext {
104        let mut cx = Self::default_keymap_context();
105        cx.add_identifier("menu");
106        cx
107    }
108
109    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
110        if !self.visible {
111            return Empty::new().into_any();
112        }
113
114        // Render the menu once at minimum width.
115        let mut collapsed_menu = self.render_menu_for_measurement(cx);
116        let expanded_menu =
117            self.render_menu(cx)
118                .constrained()
119                .dynamically(move |constraint, view, cx| {
120                    SizeConstraint::strict_along(
121                        Axis::Horizontal,
122                        collapsed_menu.layout(constraint, view, cx).0.x(),
123                    )
124                });
125
126        Overlay::new(expanded_menu)
127            .with_hoverable(true)
128            .with_fit_mode(OverlayFitMode::SnapToWindow)
129            .with_anchor_position(self.anchor_position)
130            .with_anchor_corner(self.anchor_corner)
131            .with_position_mode(self.position_mode)
132            .into_any()
133    }
134
135    fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
136        self.reset(cx);
137    }
138}
139
140impl ContextMenu {
141    pub fn new(cx: &mut ViewContext<Self>) -> Self {
142        let parent_view_id = cx.parent().unwrap();
143
144        Self {
145            show_count: 0,
146            anchor_position: Default::default(),
147            anchor_corner: AnchorCorner::TopLeft,
148            position_mode: OverlayPositionMode::Window,
149            items: Default::default(),
150            selected_index: Default::default(),
151            visible: Default::default(),
152            previously_focused_view_id: Default::default(),
153            clicked: false,
154            parent_view_id,
155            _actions_observation: cx.observe_actions(Self::action_dispatched),
156        }
157    }
158
159    pub fn visible(&self) -> bool {
160        self.visible
161    }
162
163    fn action_dispatched(&mut self, action_id: TypeId, cx: &mut ViewContext<Self>) {
164        if let Some(ix) = self
165            .items
166            .iter()
167            .position(|item| item.action_id() == Some(action_id))
168        {
169            if self.clicked {
170                self.cancel(&Default::default(), cx);
171            } else {
172                self.selected_index = Some(ix);
173                cx.notify();
174                cx.spawn(|this, mut cx| async move {
175                    cx.background().timer(Duration::from_millis(50)).await;
176                    this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?;
177                    anyhow::Ok(())
178                })
179                .detach_and_log_err(cx);
180            }
181        }
182    }
183
184    fn clicked(&mut self, _: &Clicked, _: &mut ViewContext<Self>) {
185        self.clicked = true;
186    }
187
188    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
189        if let Some(ix) = self.selected_index {
190            if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
191                cx.dispatch_any_action(action.boxed_clone());
192                self.reset(cx);
193            }
194        }
195    }
196
197    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
198        self.reset(cx);
199        let show_count = self.show_count;
200        cx.defer(move |this, cx| {
201            if cx.handle().is_focused(cx) && this.show_count == show_count {
202                let window_id = cx.window_id();
203                (**cx).focus(window_id, this.previously_focused_view_id.take());
204            }
205        });
206    }
207
208    fn reset(&mut self, cx: &mut ViewContext<Self>) {
209        self.items.clear();
210        self.visible = false;
211        self.selected_index.take();
212        self.clicked = false;
213        cx.notify();
214    }
215
216    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
217        self.selected_index = self.items.iter().position(|item| item.is_action());
218        cx.notify();
219    }
220
221    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
222        for (ix, item) in self.items.iter().enumerate().rev() {
223            if item.is_action() {
224                self.selected_index = Some(ix);
225                cx.notify();
226                break;
227            }
228        }
229    }
230
231    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
232        if let Some(ix) = self.selected_index {
233            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
234                if item.is_action() {
235                    self.selected_index = Some(ix);
236                    cx.notify();
237                    break;
238                }
239            }
240        } else {
241            self.select_first(&Default::default(), cx);
242        }
243    }
244
245    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
246        if let Some(ix) = self.selected_index {
247            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
248                if item.is_action() {
249                    self.selected_index = Some(ix);
250                    cx.notify();
251                    break;
252                }
253            }
254        } else {
255            self.select_last(&Default::default(), cx);
256        }
257    }
258
259    pub fn show(
260        &mut self,
261        anchor_position: Vector2F,
262        anchor_corner: AnchorCorner,
263        items: Vec<ContextMenuItem>,
264        cx: &mut ViewContext<Self>,
265    ) {
266        let mut items = items.into_iter().peekable();
267        if items.peek().is_some() {
268            self.items = items.collect();
269            self.anchor_position = anchor_position;
270            self.anchor_corner = anchor_corner;
271            self.visible = true;
272            self.show_count += 1;
273            if !cx.is_self_focused() {
274                self.previously_focused_view_id = cx.focused_view_id();
275            }
276            cx.focus_self();
277        } else {
278            self.visible = false;
279        }
280        cx.notify();
281    }
282
283    pub fn set_position_mode(&mut self, mode: OverlayPositionMode) {
284        self.position_mode = mode;
285    }
286
287    fn render_menu_for_measurement(&self, cx: &mut ViewContext<Self>) -> impl Element<ContextMenu> {
288        let style = cx.global::<Settings>().theme.context_menu.clone();
289        Flex::row()
290            .with_child(
291                Flex::column().with_children(self.items.iter().enumerate().map(|(ix, item)| {
292                    match item {
293                        ContextMenuItem::Item { label, .. } => {
294                            let style = style.item.style_for(
295                                &mut Default::default(),
296                                Some(ix) == self.selected_index,
297                            );
298
299                            match label {
300                                ContextMenuItemLabel::String(label) => {
301                                    Label::new(label.to_string(), style.label.clone())
302                                        .contained()
303                                        .with_style(style.container)
304                                        .into_any()
305                                }
306                                ContextMenuItemLabel::Element(element) => {
307                                    element(&mut Default::default(), style)
308                                }
309                            }
310                        }
311
312                        ContextMenuItem::Static(f) => f(cx),
313
314                        ContextMenuItem::Separator => Empty::new()
315                            .collapsed()
316                            .contained()
317                            .with_style(style.separator)
318                            .constrained()
319                            .with_height(1.)
320                            .into_any(),
321                    }
322                })),
323            )
324            .with_child(
325                Flex::column()
326                    .with_children(self.items.iter().enumerate().map(|(ix, item)| {
327                        match item {
328                            ContextMenuItem::Item { action, .. } => {
329                                let style = style.item.style_for(
330                                    &mut Default::default(),
331                                    Some(ix) == self.selected_index,
332                                );
333
334                                KeystrokeLabel::new(
335                                    self.parent_view_id,
336                                    action.boxed_clone(),
337                                    style.keystroke.container,
338                                    style.keystroke.text.clone(),
339                                )
340                                .into_any()
341                            }
342
343                            ContextMenuItem::Static(_) => Empty::new().into_any(),
344
345                            ContextMenuItem::Separator => Empty::new()
346                                .collapsed()
347                                .constrained()
348                                .with_height(1.)
349                                .contained()
350                                .with_style(style.separator)
351                                .into_any(),
352                        }
353                    }))
354                    .contained()
355                    .with_margin_left(style.keystroke_margin),
356            )
357            .contained()
358            .with_style(style.container)
359    }
360
361    fn render_menu(&self, cx: &mut ViewContext<Self>) -> impl Element<ContextMenu> {
362        enum Menu {}
363        enum MenuItem {}
364
365        let style = cx.global::<Settings>().theme.context_menu.clone();
366
367        MouseEventHandler::<Menu, ContextMenu>::new(0, cx, |_, cx| {
368            Flex::column()
369                .with_children(self.items.iter().enumerate().map(|(ix, item)| {
370                    match item {
371                        ContextMenuItem::Item { label, action } => {
372                            let action = action.boxed_clone();
373                            let view_id = self.parent_view_id;
374                            MouseEventHandler::<MenuItem, ContextMenu>::new(ix, cx, |state, _| {
375                                let style =
376                                    style.item.style_for(state, Some(ix) == self.selected_index);
377
378                                Flex::row()
379                                    .with_child(match label {
380                                        ContextMenuItemLabel::String(label) => {
381                                            Label::new(label.clone(), style.label.clone())
382                                                .contained()
383                                                .into_any()
384                                        }
385                                        ContextMenuItemLabel::Element(element) => {
386                                            element(state, style)
387                                        }
388                                    })
389                                    .with_child({
390                                        KeystrokeLabel::new(
391                                            view_id,
392                                            action.boxed_clone(),
393                                            style.keystroke.container,
394                                            style.keystroke.text.clone(),
395                                        )
396                                        .flex_float()
397                                    })
398                                    .contained()
399                                    .with_style(style.container)
400                            })
401                            .with_cursor_style(CursorStyle::PointingHand)
402                            .on_up(MouseButton::Left, |_, _, _| {}) // Capture these events
403                            .on_down(MouseButton::Left, |_, _, _| {}) // Capture these events
404                            .on_click(MouseButton::Left, move |_, _, cx| {
405                                let window_id = cx.window_id();
406                                cx.dispatch_action(Clicked);
407                                cx.dispatch_any_action_at(window_id, view_id, action.boxed_clone());
408                            })
409                            .on_drag(MouseButton::Left, |_, _, _| {})
410                            .into_any()
411                        }
412
413                        ContextMenuItem::Static(f) => f(cx),
414
415                        ContextMenuItem::Separator => Empty::new()
416                            .constrained()
417                            .with_height(1.)
418                            .contained()
419                            .with_style(style.separator)
420                            .into_any(),
421                    }
422                }))
423                .contained()
424                .with_style(style.container)
425        })
426        .on_down_out(MouseButton::Left, |_, _, cx| cx.dispatch_action(Cancel))
427        .on_down_out(MouseButton::Right, |_, _, cx| cx.dispatch_action(Cancel))
428    }
429}