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