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