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