popover_menu.rs

  1use std::{cell::RefCell, rc::Rc};
  2
  3use gpui::{
  4    anchored, deferred, div, point, prelude::FluentBuilder, px, AnchorCorner, AnyElement, Bounds,
  5    DismissEvent, DispatchPhase, Element, ElementId, GlobalElementId, HitboxId, InteractiveElement,
  6    IntoElement, LayoutId, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, View,
  7    VisualContext, WindowContext,
  8};
  9
 10use crate::prelude::*;
 11
 12pub trait PopoverTrigger: IntoElement + Clickable + Selectable + 'static {}
 13
 14impl<T: IntoElement + Clickable + Selectable + 'static> PopoverTrigger for T {}
 15
 16pub struct PopoverMenuHandle<M>(Rc<RefCell<Option<PopoverMenuHandleState<M>>>>);
 17
 18impl<M> Clone for PopoverMenuHandle<M> {
 19    fn clone(&self) -> Self {
 20        Self(self.0.clone())
 21    }
 22}
 23
 24impl<M> Default for PopoverMenuHandle<M> {
 25    fn default() -> Self {
 26        Self(Rc::default())
 27    }
 28}
 29
 30struct PopoverMenuHandleState<M> {
 31    menu_builder: Rc<dyn Fn(&mut WindowContext) -> Option<View<M>>>,
 32    menu: Rc<RefCell<Option<View<M>>>>,
 33}
 34
 35impl<M: ManagedView> PopoverMenuHandle<M> {
 36    pub fn show(&self, cx: &mut WindowContext) {
 37        if let Some(state) = self.0.borrow().as_ref() {
 38            show_menu(&state.menu_builder, &state.menu, cx);
 39        }
 40    }
 41
 42    pub fn hide(&self, cx: &mut WindowContext) {
 43        if let Some(state) = self.0.borrow().as_ref() {
 44            if let Some(menu) = state.menu.borrow().as_ref() {
 45                menu.update(cx, |_, cx| cx.emit(DismissEvent));
 46            }
 47        }
 48    }
 49
 50    pub fn toggle(&self, cx: &mut WindowContext) {
 51        if let Some(state) = self.0.borrow().as_ref() {
 52            if state.menu.borrow().is_some() {
 53                self.hide(cx);
 54            } else {
 55                self.show(cx);
 56            }
 57        }
 58    }
 59}
 60
 61pub struct PopoverMenu<M: ManagedView> {
 62    id: ElementId,
 63    child_builder: Option<
 64        Box<
 65            dyn FnOnce(
 66                    Rc<RefCell<Option<View<M>>>>,
 67                    Option<Rc<dyn Fn(&mut WindowContext) -> Option<View<M>> + 'static>>,
 68                ) -> AnyElement
 69                + 'static,
 70        >,
 71    >,
 72    menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> Option<View<M>> + 'static>>,
 73    anchor: AnchorCorner,
 74    attach: Option<AnchorCorner>,
 75    offset: Option<Point<Pixels>>,
 76    trigger_handle: Option<PopoverMenuHandle<M>>,
 77}
 78
 79impl<M: ManagedView> PopoverMenu<M> {
 80    pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> Option<View<M>> + 'static) -> Self {
 81        self.menu_builder = Some(Rc::new(f));
 82        self
 83    }
 84
 85    pub fn with_handle(mut self, handle: PopoverMenuHandle<M>) -> Self {
 86        self.trigger_handle = Some(handle);
 87        self
 88    }
 89
 90    pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
 91        self.child_builder = Some(Box::new(|menu, builder| {
 92            let open = menu.borrow().is_some();
 93            t.selected(open)
 94                .when_some(builder, |el, builder| {
 95                    el.on_click(move |_, cx| show_menu(&builder, &menu, cx))
 96                })
 97                .into_any_element()
 98        }));
 99        self
100    }
101
102    /// anchor defines which corner of the menu to anchor to the attachment point
103    /// (by default the cursor position, but see attach)
104    pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
105        self.anchor = anchor;
106        self
107    }
108
109    /// attach defines which corner of the handle to attach the menu's anchor to
110    pub fn attach(mut self, attach: AnchorCorner) -> Self {
111        self.attach = Some(attach);
112        self
113    }
114
115    /// offset offsets the position of the content by that many pixels.
116    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
117        self.offset = Some(offset);
118        self
119    }
120
121    fn resolved_attach(&self) -> AnchorCorner {
122        self.attach.unwrap_or_else(|| match self.anchor {
123            AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
124            AnchorCorner::TopRight => AnchorCorner::BottomRight,
125            AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
126            AnchorCorner::BottomRight => AnchorCorner::TopRight,
127        })
128    }
129
130    fn resolved_offset(&self, cx: &WindowContext) -> Point<Pixels> {
131        self.offset.unwrap_or_else(|| {
132            // Default offset = 4px padding + 1px border
133            let offset = rems_from_px(5.) * cx.rem_size();
134            match self.anchor {
135                AnchorCorner::TopRight | AnchorCorner::BottomRight => point(offset, px(0.)),
136                AnchorCorner::TopLeft | AnchorCorner::BottomLeft => point(-offset, px(0.)),
137            }
138        })
139    }
140}
141
142fn show_menu<M: ManagedView>(
143    builder: &Rc<dyn Fn(&mut WindowContext) -> Option<View<M>>>,
144    menu: &Rc<RefCell<Option<View<M>>>>,
145    cx: &mut WindowContext,
146) {
147    let Some(new_menu) = (builder)(cx) else {
148        return;
149    };
150    let menu2 = menu.clone();
151    let previous_focus_handle = cx.focused();
152
153    cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| {
154        if modal.focus_handle(cx).contains_focused(cx) {
155            if let Some(previous_focus_handle) = previous_focus_handle.as_ref() {
156                cx.focus(previous_focus_handle);
157            }
158        }
159        *menu2.borrow_mut() = None;
160        cx.refresh();
161    })
162    .detach();
163    cx.focus_view(&new_menu);
164    *menu.borrow_mut() = Some(new_menu);
165    cx.refresh();
166}
167
168/// Creates a [`PopoverMenu`]
169pub fn popover_menu<M: ManagedView>(id: impl Into<ElementId>) -> PopoverMenu<M> {
170    PopoverMenu {
171        id: id.into(),
172        child_builder: None,
173        menu_builder: None,
174        anchor: AnchorCorner::TopLeft,
175        attach: None,
176        offset: None,
177        trigger_handle: None,
178    }
179}
180
181pub struct PopoverMenuElementState<M> {
182    menu: Rc<RefCell<Option<View<M>>>>,
183    child_bounds: Option<Bounds<Pixels>>,
184}
185
186impl<M> Clone for PopoverMenuElementState<M> {
187    fn clone(&self) -> Self {
188        Self {
189            menu: Rc::clone(&self.menu),
190            child_bounds: self.child_bounds,
191        }
192    }
193}
194
195impl<M> Default for PopoverMenuElementState<M> {
196    fn default() -> Self {
197        Self {
198            menu: Rc::default(),
199            child_bounds: None,
200        }
201    }
202}
203
204pub struct PopoverMenuFrameState {
205    child_layout_id: Option<LayoutId>,
206    child_element: Option<AnyElement>,
207    menu_element: Option<AnyElement>,
208}
209
210impl<M: ManagedView> Element for PopoverMenu<M> {
211    type RequestLayoutState = PopoverMenuFrameState;
212    type PrepaintState = Option<HitboxId>;
213
214    fn id(&self) -> Option<ElementId> {
215        Some(self.id.clone())
216    }
217
218    fn request_layout(
219        &mut self,
220        global_id: Option<&GlobalElementId>,
221        cx: &mut WindowContext,
222    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
223        cx.with_element_state(
224            global_id.unwrap(),
225            |element_state: Option<PopoverMenuElementState<M>>, cx| {
226                let element_state = element_state.unwrap_or_default();
227                let mut menu_layout_id = None;
228
229                let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
230                    let mut anchored = anchored().snap_to_window().anchor(self.anchor);
231                    if let Some(child_bounds) = element_state.child_bounds {
232                        anchored = anchored.position(
233                            self.resolved_attach().corner(child_bounds) + self.resolved_offset(cx),
234                        );
235                    }
236                    let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
237                        .with_priority(1)
238                        .into_any();
239
240                    menu_layout_id = Some(element.request_layout(cx));
241                    element
242                });
243
244                let mut child_element = self.child_builder.take().map(|child_builder| {
245                    (child_builder)(element_state.menu.clone(), self.menu_builder.clone())
246                });
247
248                if let Some(trigger_handle) = self.trigger_handle.take() {
249                    if let Some(menu_builder) = self.menu_builder.clone() {
250                        *trigger_handle.0.borrow_mut() = Some(PopoverMenuHandleState {
251                            menu_builder,
252                            menu: element_state.menu.clone(),
253                        });
254                    }
255                }
256
257                let child_layout_id = child_element
258                    .as_mut()
259                    .map(|child_element| child_element.request_layout(cx));
260
261                let layout_id = cx.request_layout(
262                    gpui::Style::default(),
263                    menu_layout_id.into_iter().chain(child_layout_id),
264                );
265
266                (
267                    (
268                        layout_id,
269                        PopoverMenuFrameState {
270                            child_element,
271                            child_layout_id,
272                            menu_element,
273                        },
274                    ),
275                    element_state,
276                )
277            },
278        )
279    }
280
281    fn prepaint(
282        &mut self,
283        global_id: Option<&GlobalElementId>,
284        _bounds: Bounds<Pixels>,
285        request_layout: &mut Self::RequestLayoutState,
286        cx: &mut WindowContext,
287    ) -> Option<HitboxId> {
288        if let Some(child) = request_layout.child_element.as_mut() {
289            child.prepaint(cx);
290        }
291
292        if let Some(menu) = request_layout.menu_element.as_mut() {
293            menu.prepaint(cx);
294        }
295
296        let hitbox_id = request_layout.child_layout_id.map(|layout_id| {
297            let bounds = cx.layout_bounds(layout_id);
298            cx.with_element_state(global_id.unwrap(), |element_state, _cx| {
299                let mut element_state: PopoverMenuElementState<M> = element_state.unwrap();
300                element_state.child_bounds = Some(bounds);
301                ((), element_state)
302            });
303
304            cx.insert_hitbox(bounds, false).id
305        });
306
307        hitbox_id
308    }
309
310    fn paint(
311        &mut self,
312        _id: Option<&GlobalElementId>,
313        _: Bounds<gpui::Pixels>,
314        request_layout: &mut Self::RequestLayoutState,
315        child_hitbox: &mut Option<HitboxId>,
316        cx: &mut WindowContext,
317    ) {
318        if let Some(mut child) = request_layout.child_element.take() {
319            child.paint(cx);
320        }
321
322        if let Some(mut menu) = request_layout.menu_element.take() {
323            menu.paint(cx);
324
325            if let Some(child_hitbox) = *child_hitbox {
326                // Mouse-downing outside the menu dismisses it, so we don't
327                // want a click on the toggle to re-open it.
328                cx.on_mouse_event(move |_: &MouseDownEvent, phase, cx| {
329                    if phase == DispatchPhase::Bubble && child_hitbox.is_hovered(cx) {
330                        cx.stop_propagation()
331                    }
332                })
333            }
334        }
335    }
336}
337
338impl<M: ManagedView> IntoElement for PopoverMenu<M> {
339    type Element = Self;
340
341    fn into_element(self) -> Self::Element {
342        self
343    }
344}