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, AnyView, App,
  7    Bounds, 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    /// This method prevents the trigger button tooltip from being seen when the menu is open.
182    pub fn trigger_with_tooltip<T: PopoverTrigger + ButtonCommon>(
183        mut self,
184        t: T,
185        tooltip_builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
186    ) -> Self {
187        let on_open = self.on_open.clone();
188        self.child_builder = Some(Box::new(move |menu, builder| {
189            let open = menu.borrow().is_some();
190            t.toggle_state(open)
191                .when_some(builder, |el, builder| {
192                    el.on_click(move |_, window, cx| {
193                        show_menu(&builder, &menu, on_open.clone(), window, cx)
194                    })
195                    .when(!open, |t| {
196                        t.tooltip(move |window, cx| tooltip_builder(window, cx))
197                    })
198                })
199                .into_any_element()
200        }));
201        self
202    }
203
204    /// Defines which corner of the menu to anchor to the attachment point.
205    /// By default, it uses the cursor position. Also see the `attach` method.
206    pub fn anchor(mut self, anchor: Corner) -> Self {
207        self.anchor = anchor;
208        self
209    }
210
211    /// Defines which corner of the handle to attach the menu's anchor to.
212    pub fn attach(mut self, attach: Corner) -> Self {
213        self.attach = Some(attach);
214        self
215    }
216
217    /// Offsets the position of the content by that many pixels.
218    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
219        self.offset = Some(offset);
220        self
221    }
222
223    /// Attaches something upon opening the menu.
224    pub fn on_open(mut self, on_open: Rc<dyn Fn(&mut Window, &mut App)>) -> Self {
225        self.on_open = Some(on_open);
226        self
227    }
228
229    fn resolved_attach(&self) -> Corner {
230        self.attach.unwrap_or(match self.anchor {
231            Corner::TopLeft => Corner::BottomLeft,
232            Corner::TopRight => Corner::BottomRight,
233            Corner::BottomLeft => Corner::TopLeft,
234            Corner::BottomRight => Corner::TopRight,
235        })
236    }
237
238    fn resolved_offset(&self, window: &mut Window) -> Point<Pixels> {
239        self.offset.unwrap_or_else(|| {
240            // Default offset = 4px padding + 1px border
241            let offset = rems_from_px(5.) * window.rem_size();
242            match self.anchor {
243                Corner::TopRight | Corner::BottomRight => point(offset, px(0.)),
244                Corner::TopLeft | Corner::BottomLeft => point(-offset, px(0.)),
245            }
246        })
247    }
248}
249
250fn show_menu<M: ManagedView>(
251    builder: &Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
252    menu: &Rc<RefCell<Option<Entity<M>>>>,
253    on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
254    window: &mut Window,
255    cx: &mut App,
256) {
257    let Some(new_menu) = (builder)(window, cx) else {
258        return;
259    };
260    let menu2 = menu.clone();
261    let previous_focus_handle = window.focused(cx);
262
263    window
264        .subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| {
265            if modal.focus_handle(cx).contains_focused(window, cx) {
266                if let Some(previous_focus_handle) = previous_focus_handle.as_ref() {
267                    window.focus(previous_focus_handle);
268                }
269            }
270            *menu2.borrow_mut() = None;
271            window.refresh();
272        })
273        .detach();
274    window.focus(&new_menu.focus_handle(cx));
275    *menu.borrow_mut() = Some(new_menu);
276    window.refresh();
277
278    if let Some(on_open) = on_open {
279        on_open(window, cx);
280    }
281}
282
283pub struct PopoverMenuElementState<M> {
284    menu: Rc<RefCell<Option<Entity<M>>>>,
285    child_bounds: Option<Bounds<Pixels>>,
286}
287
288impl<M> Clone for PopoverMenuElementState<M> {
289    fn clone(&self) -> Self {
290        Self {
291            menu: Rc::clone(&self.menu),
292            child_bounds: self.child_bounds,
293        }
294    }
295}
296
297impl<M> Default for PopoverMenuElementState<M> {
298    fn default() -> Self {
299        Self {
300            menu: Rc::default(),
301            child_bounds: None,
302        }
303    }
304}
305
306pub struct PopoverMenuFrameState<M: ManagedView> {
307    child_layout_id: Option<LayoutId>,
308    child_element: Option<AnyElement>,
309    menu_element: Option<AnyElement>,
310    menu_handle: Rc<RefCell<Option<Entity<M>>>>,
311}
312
313impl<M: ManagedView> Element for PopoverMenu<M> {
314    type RequestLayoutState = PopoverMenuFrameState<M>;
315    type PrepaintState = Option<HitboxId>;
316
317    fn id(&self) -> Option<ElementId> {
318        Some(self.id.clone())
319    }
320
321    fn request_layout(
322        &mut self,
323        global_id: Option<&GlobalElementId>,
324        window: &mut Window,
325        cx: &mut App,
326    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
327        window.with_element_state(
328            global_id.unwrap(),
329            |element_state: Option<PopoverMenuElementState<M>>, window| {
330                let element_state = element_state.unwrap_or_default();
331                let mut menu_layout_id = None;
332
333                let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
334                    let offset = self.resolved_offset(window);
335                    let mut anchored = anchored()
336                        .snap_to_window_with_margin(px(8.))
337                        .anchor(self.anchor)
338                        .offset(offset);
339                    if let Some(child_bounds) = element_state.child_bounds {
340                        anchored =
341                            anchored.position(child_bounds.corner(self.resolved_attach()) + offset);
342                    }
343                    let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
344                        .with_priority(1)
345                        .into_any();
346
347                    menu_layout_id = Some(element.request_layout(window, cx));
348                    element
349                });
350
351                let mut child_element = self.child_builder.take().map(|child_builder| {
352                    (child_builder)(element_state.menu.clone(), self.menu_builder.clone())
353                });
354
355                if let Some(trigger_handle) = self.trigger_handle.take() {
356                    if let Some(menu_builder) = self.menu_builder.clone() {
357                        *trigger_handle.0.borrow_mut() = Some(PopoverMenuHandleState {
358                            menu_builder,
359                            menu: element_state.menu.clone(),
360                            on_open: self.on_open.clone(),
361                        });
362                    }
363                }
364
365                let child_layout_id = child_element
366                    .as_mut()
367                    .map(|child_element| child_element.request_layout(window, cx));
368
369                let mut style = Style::default();
370                if self.full_width {
371                    style.size = size(relative(1.).into(), Length::Auto);
372                }
373
374                let layout_id = window.request_layout(
375                    style,
376                    menu_layout_id.into_iter().chain(child_layout_id),
377                    cx,
378                );
379
380                (
381                    (
382                        layout_id,
383                        PopoverMenuFrameState {
384                            child_element,
385                            child_layout_id,
386                            menu_element,
387                            menu_handle: element_state.menu.clone(),
388                        },
389                    ),
390                    element_state,
391                )
392            },
393        )
394    }
395
396    fn prepaint(
397        &mut self,
398        global_id: Option<&GlobalElementId>,
399        _bounds: Bounds<Pixels>,
400        request_layout: &mut Self::RequestLayoutState,
401        window: &mut Window,
402        cx: &mut App,
403    ) -> Option<HitboxId> {
404        if let Some(child) = request_layout.child_element.as_mut() {
405            child.prepaint(window, cx);
406        }
407
408        if let Some(menu) = request_layout.menu_element.as_mut() {
409            menu.prepaint(window, cx);
410        }
411
412        request_layout.child_layout_id.map(|layout_id| {
413            let bounds = window.layout_bounds(layout_id);
414            window.with_element_state(global_id.unwrap(), |element_state, _cx| {
415                let mut element_state: PopoverMenuElementState<M> = element_state.unwrap();
416                element_state.child_bounds = Some(bounds);
417                ((), element_state)
418            });
419
420            window.insert_hitbox(bounds, false).id
421        })
422    }
423
424    fn paint(
425        &mut self,
426        _id: Option<&GlobalElementId>,
427        _: Bounds<gpui::Pixels>,
428        request_layout: &mut Self::RequestLayoutState,
429        child_hitbox: &mut Option<HitboxId>,
430        window: &mut Window,
431        cx: &mut App,
432    ) {
433        if let Some(mut child) = request_layout.child_element.take() {
434            child.paint(window, cx);
435        }
436
437        if let Some(mut menu) = request_layout.menu_element.take() {
438            menu.paint(window, cx);
439
440            if let Some(child_hitbox) = *child_hitbox {
441                let menu_handle = request_layout.menu_handle.clone();
442                // Mouse-downing outside the menu dismisses it, so we don't
443                // want a click on the toggle to re-open it.
444                window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
445                    if phase == DispatchPhase::Bubble && child_hitbox.is_hovered(window) {
446                        if let Some(menu) = menu_handle.borrow().as_ref() {
447                            menu.update(cx, |_, cx| {
448                                cx.emit(DismissEvent);
449                            });
450                        }
451                        cx.stop_propagation();
452                    }
453                })
454            }
455        }
456    }
457}
458
459impl<M: ManagedView> IntoElement for PopoverMenu<M> {
460    type Element = Self;
461
462    fn into_element(self) -> Self::Element {
463        self
464    }
465}