popover_menu.rs

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