popover_menu.rs

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