context_menu.rs

  1use std::cell::RefCell;
  2use std::rc::Rc;
  3
  4use crate::prelude::*;
  5use crate::{v_stack, Label, List, ListEntry, ListItem, ListSeparator, ListSubHeader};
  6use gpui::{
  7    overlay, px, Action, AnchorCorner, AnyElement, AppContext, Bounds, DispatchPhase, Div,
  8    EventEmitter, FocusHandle, FocusableView, LayoutId, ManagedView, Manager, MouseButton,
  9    MouseDownEvent, Pixels, Point, Render, View, VisualContext, WeakView,
 10};
 11
 12pub enum ContextMenuItem<V> {
 13    Separator(ListSeparator),
 14    Header(ListSubHeader),
 15    Entry(
 16        ListEntry<ContextMenu<V>>,
 17        Rc<dyn Fn(&mut V, &mut ViewContext<V>)>,
 18    ),
 19}
 20
 21pub struct ContextMenu<V> {
 22    items: Vec<ContextMenuItem<V>>,
 23    focus_handle: FocusHandle,
 24    handle: WeakView<V>,
 25}
 26
 27impl<V: Render> FocusableView for ContextMenu<V> {
 28    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 29        self.focus_handle.clone()
 30    }
 31}
 32
 33impl<V: Render> EventEmitter<Manager> for ContextMenu<V> {}
 34
 35impl<V: Render> ContextMenu<V> {
 36    pub fn build(
 37        cx: &mut ViewContext<V>,
 38        f: impl FnOnce(Self, &mut ViewContext<Self>) -> Self,
 39    ) -> View<Self> {
 40        let handle = cx.view().downgrade();
 41        cx.build_view(|cx| {
 42            f(
 43                Self {
 44                    handle,
 45                    items: Default::default(),
 46                    focus_handle: cx.focus_handle(),
 47                },
 48                cx,
 49            )
 50        })
 51    }
 52
 53    pub fn header(mut self, title: impl Into<SharedString>) -> Self {
 54        self.items
 55            .push(ContextMenuItem::Header(ListSubHeader::new(title)));
 56        self
 57    }
 58
 59    pub fn separator(mut self) -> Self {
 60        self.items.push(ContextMenuItem::Separator(ListSeparator));
 61        self
 62    }
 63
 64    pub fn entry(
 65        mut self,
 66        view: ListEntry<Self>,
 67        on_click: impl Fn(&mut V, &mut ViewContext<V>) + 'static,
 68    ) -> Self {
 69        self.items
 70            .push(ContextMenuItem::Entry(view, Rc::new(on_click)));
 71        self
 72    }
 73
 74    pub fn action(self, view: ListEntry<Self>, action: Box<dyn Action>) -> Self {
 75        // todo: add the keybindings to the list entry
 76        self.entry(view, move |_, cx| cx.dispatch_action(action.boxed_clone()))
 77    }
 78
 79    pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 80        // todo!()
 81        cx.emit(Manager::Dismiss);
 82    }
 83
 84    pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 85        cx.emit(Manager::Dismiss);
 86    }
 87}
 88
 89impl<V: Render> Render for ContextMenu<V> {
 90    type Element = Div<Self>;
 91
 92    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
 93        div().elevation_2(cx).flex().flex_row().child(
 94            v_stack()
 95                .min_w(px(200.))
 96                .track_focus(&self.focus_handle)
 97                .on_mouse_down_out(|this: &mut Self, _, cx| this.cancel(&Default::default(), cx))
 98                // .on_action(ContextMenu::select_first)
 99                // .on_action(ContextMenu::select_last)
100                // .on_action(ContextMenu::select_next)
101                // .on_action(ContextMenu::select_prev)
102                .on_action(ContextMenu::confirm)
103                .on_action(ContextMenu::cancel)
104                .flex_none()
105                // .bg(cx.theme().colors().elevated_surface_background)
106                // .border()
107                // .border_color(cx.theme().colors().border)
108                .child(List::new(
109                    self.items
110                        .iter()
111                        .map(|item| match item {
112                            ContextMenuItem::Separator(separator) => {
113                                ListItem::Separator(separator.clone())
114                            }
115                            ContextMenuItem::Header(header) => ListItem::Header(header.clone()),
116                            ContextMenuItem::Entry(entry, callback) => {
117                                let callback = callback.clone();
118                                let handle = self.handle.clone();
119                                ListItem::Entry(entry.clone().on_click(move |this, cx| {
120                                    handle.update(cx, |view, cx| callback(view, cx)).ok();
121                                    cx.emit(Manager::Dismiss);
122                                }))
123                            }
124                        })
125                        .collect(),
126                )),
127        )
128    }
129}
130
131pub struct MenuHandle<V: 'static, M: ManagedView> {
132    id: Option<ElementId>,
133    child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement<V> + 'static>>,
134    menu_builder: Option<Rc<dyn Fn(&mut V, &mut ViewContext<V>) -> View<M> + 'static>>,
135
136    anchor: Option<AnchorCorner>,
137    attach: Option<AnchorCorner>,
138}
139
140impl<V: 'static, M: ManagedView> MenuHandle<V, M> {
141    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
142        self.id = Some(id.into());
143        self
144    }
145
146    pub fn menu(mut self, f: impl Fn(&mut V, &mut ViewContext<V>) -> View<M> + 'static) -> Self {
147        self.menu_builder = Some(Rc::new(f));
148        self
149    }
150
151    pub fn child<R: Component<V>>(mut self, f: impl FnOnce(bool) -> R + 'static) -> Self {
152        self.child_builder = Some(Box::new(|b| f(b).render()));
153        self
154    }
155
156    /// anchor defines which corner of the menu to anchor to the attachment point
157    /// (by default the cursor position, but see attach)
158    pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
159        self.anchor = Some(anchor);
160        self
161    }
162
163    /// attach defines which corner of the handle to attach the menu's anchor to
164    pub fn attach(mut self, attach: AnchorCorner) -> Self {
165        self.attach = Some(attach);
166        self
167    }
168}
169
170pub fn menu_handle<V: 'static, M: ManagedView>() -> MenuHandle<V, M> {
171    MenuHandle {
172        id: None,
173        child_builder: None,
174        menu_builder: None,
175        anchor: None,
176        attach: None,
177    }
178}
179
180pub struct MenuHandleState<V, M> {
181    menu: Rc<RefCell<Option<View<M>>>>,
182    position: Rc<RefCell<Point<Pixels>>>,
183    child_layout_id: Option<LayoutId>,
184    child_element: Option<AnyElement<V>>,
185    menu_element: Option<AnyElement<V>>,
186}
187impl<V: 'static, M: ManagedView> Element<V> for MenuHandle<V, M> {
188    type ElementState = MenuHandleState<V, M>;
189
190    fn element_id(&self) -> Option<gpui::ElementId> {
191        Some(self.id.clone().expect("menu_handle must have an id()"))
192    }
193
194    fn layout(
195        &mut self,
196        view_state: &mut V,
197        element_state: Option<Self::ElementState>,
198        cx: &mut crate::ViewContext<V>,
199    ) -> (gpui::LayoutId, Self::ElementState) {
200        let (menu, position) = if let Some(element_state) = element_state {
201            (element_state.menu, element_state.position)
202        } else {
203            (Rc::default(), Rc::default())
204        };
205
206        let mut menu_layout_id = None;
207
208        let menu_element = menu.borrow_mut().as_mut().map(|menu| {
209            let mut overlay = overlay::<V>().snap_to_window();
210            if let Some(anchor) = self.anchor {
211                overlay = overlay.anchor(anchor);
212            }
213            overlay = overlay.position(*position.borrow());
214
215            let mut view = overlay.child(menu.clone()).render();
216            menu_layout_id = Some(view.layout(view_state, cx));
217            view
218        });
219
220        let mut child_element = self
221            .child_builder
222            .take()
223            .map(|child_builder| (child_builder)(menu.borrow().is_some()));
224
225        let child_layout_id = child_element
226            .as_mut()
227            .map(|child_element| child_element.layout(view_state, cx));
228
229        let layout_id = cx.request_layout(
230            &gpui::Style::default(),
231            menu_layout_id.into_iter().chain(child_layout_id),
232        );
233
234        (
235            layout_id,
236            MenuHandleState {
237                menu,
238                position,
239                child_element,
240                child_layout_id,
241                menu_element,
242            },
243        )
244    }
245
246    fn paint(
247        &mut self,
248        bounds: Bounds<gpui::Pixels>,
249        view_state: &mut V,
250        element_state: &mut Self::ElementState,
251        cx: &mut crate::ViewContext<V>,
252    ) {
253        if let Some(child) = element_state.child_element.as_mut() {
254            child.paint(view_state, cx);
255        }
256
257        if let Some(menu) = element_state.menu_element.as_mut() {
258            menu.paint(view_state, cx);
259            return;
260        }
261
262        let Some(builder) = self.menu_builder.clone() else {
263            return;
264        };
265        let menu = element_state.menu.clone();
266        let position = element_state.position.clone();
267        let attach = self.attach.clone();
268        let child_layout_id = element_state.child_layout_id.clone();
269
270        cx.on_mouse_event(move |view_state, event: &MouseDownEvent, phase, cx| {
271            if phase == DispatchPhase::Bubble
272                && event.button == MouseButton::Right
273                && bounds.contains_point(&event.position)
274            {
275                cx.stop_propagation();
276                cx.prevent_default();
277
278                let new_menu = (builder)(view_state, cx);
279                let menu2 = menu.clone();
280                cx.subscribe(&new_menu, move |this, modal, e, cx| match e {
281                    &Manager::Dismiss => {
282                        *menu2.borrow_mut() = None;
283                        cx.notify();
284                    }
285                })
286                .detach();
287                cx.focus_view(&new_menu);
288                *menu.borrow_mut() = Some(new_menu);
289
290                *position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() {
291                    attach
292                        .unwrap()
293                        .corner(cx.layout_bounds(child_layout_id.unwrap()))
294                } else {
295                    cx.mouse_position()
296                };
297                cx.notify();
298            }
299        });
300    }
301}
302
303impl<V: 'static, M: ManagedView> Component<V> for MenuHandle<V, M> {
304    fn render(self) -> AnyElement<V> {
305        AnyElement::new(self)
306    }
307}
308
309#[cfg(feature = "stories")]
310pub use stories::*;
311
312#[cfg(feature = "stories")]
313mod stories {
314    use super::*;
315    use crate::story::Story;
316    use gpui::{actions, Div, Render};
317
318    actions!(PrintCurrentDate, PrintBestFood);
319
320    fn build_menu<V: Render>(
321        cx: &mut ViewContext<V>,
322        header: impl Into<SharedString>,
323    ) -> View<ContextMenu<V>> {
324        let handle = cx.view().clone();
325        ContextMenu::build(cx, |menu, _| {
326            menu.header(header)
327                .separator()
328                .entry(ListEntry::new(Label::new("Print current time")), |v, cx| {
329                    println!("dispatching PrintCurrentTime action");
330                    cx.dispatch_action(PrintCurrentDate.boxed_clone())
331                })
332                .entry(ListEntry::new(Label::new("Print best food")), |v, cx| {
333                    cx.dispatch_action(PrintBestFood.boxed_clone())
334                })
335        })
336    }
337
338    pub struct ContextMenuStory;
339
340    impl Render for ContextMenuStory {
341        type Element = Div<Self>;
342
343        fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
344            Story::container(cx)
345                .on_action(|_, _: &PrintCurrentDate, _| {
346                    println!("printing unix time!");
347                    if let Ok(unix_time) = std::time::UNIX_EPOCH.elapsed() {
348                        println!("Current Unix time is {:?}", unix_time.as_secs());
349                    }
350                })
351                .on_action(|_, _: &PrintBestFood, _| {
352                    println!("burrito");
353                })
354                .flex()
355                .flex_row()
356                .justify_between()
357                .child(
358                    div()
359                        .flex()
360                        .flex_col()
361                        .justify_between()
362                        .child(
363                            menu_handle()
364                                .id("test2")
365                                .child(|is_open| {
366                                    Label::new(if is_open {
367                                        "TOP LEFT"
368                                    } else {
369                                        "RIGHT CLICK ME"
370                                    })
371                                    .render()
372                                })
373                                .menu(move |_, cx| build_menu(cx, "top left")),
374                        )
375                        .child(
376                            menu_handle()
377                                .id("test1")
378                                .child(|is_open| {
379                                    Label::new(if is_open {
380                                        "BOTTOM LEFT"
381                                    } else {
382                                        "RIGHT CLICK ME"
383                                    })
384                                    .render()
385                                })
386                                .anchor(AnchorCorner::BottomLeft)
387                                .attach(AnchorCorner::TopLeft)
388                                .menu(move |_, cx| build_menu(cx, "bottom left")),
389                        ),
390                )
391                .child(
392                    div()
393                        .flex()
394                        .flex_col()
395                        .justify_between()
396                        .child(
397                            menu_handle()
398                                .id("test3")
399                                .child(|is_open| {
400                                    Label::new(if is_open {
401                                        "TOP RIGHT"
402                                    } else {
403                                        "RIGHT CLICK ME"
404                                    })
405                                    .render()
406                                })
407                                .anchor(AnchorCorner::TopRight)
408                                .menu(move |_, cx| build_menu(cx, "top right")),
409                        )
410                        .child(
411                            menu_handle()
412                                .id("test4")
413                                .child(|is_open| {
414                                    Label::new(if is_open {
415                                        "BOTTOM RIGHT"
416                                    } else {
417                                        "RIGHT CLICK ME"
418                                    })
419                                    .render()
420                                })
421                                .anchor(AnchorCorner::BottomRight)
422                                .attach(AnchorCorner::TopRight)
423                                .menu(move |_, cx| build_menu(cx, "bottom right")),
424                        ),
425                )
426        }
427    }
428}