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