popover_menu.rs

  1use std::{cell::RefCell, rc::Rc};
  2
  3use gpui::{
  4    Anchor, AnyElement, AnyView, App, Bounds, DismissEvent, DispatchPhase, Element, ElementId,
  5    Entity, Focusable as _, GlobalElementId, HitboxBehavior, HitboxId, InteractiveElement,
  6    IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement, Pixels, Point,
  7    Style, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, size,
  8};
  9
 10use crate::prelude::*;
 11
 12pub trait PopoverTrigger: IntoElement + Clickable + Toggleable + 'static {}
 13
 14impl<T: IntoElement + Clickable + Toggleable + 'static> PopoverTrigger for T {}
 15
 16impl<T: Clickable> Clickable for gpui::AnimationElement<T>
 17where
 18    T: Clickable + 'static,
 19{
 20    fn on_click(
 21        self,
 22        handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
 23    ) -> Self {
 24        self.map_element(|e| e.on_click(handler))
 25    }
 26
 27    fn cursor_style(self, cursor_style: gpui::CursorStyle) -> Self {
 28        self.map_element(|e| e.cursor_style(cursor_style))
 29    }
 30}
 31
 32impl<T: Toggleable> Toggleable for gpui::AnimationElement<T>
 33where
 34    T: Toggleable + 'static,
 35{
 36    fn toggle_state(self, selected: bool) -> Self {
 37        self.map_element(|e| e.toggle_state(selected))
 38    }
 39}
 40
 41pub struct PopoverMenuHandle<M>(Rc<RefCell<Option<PopoverMenuHandleState<M>>>>);
 42
 43impl<M> Clone for PopoverMenuHandle<M> {
 44    fn clone(&self) -> Self {
 45        Self(self.0.clone())
 46    }
 47}
 48
 49impl<M> Default for PopoverMenuHandle<M> {
 50    fn default() -> Self {
 51        Self(Rc::default())
 52    }
 53}
 54
 55struct PopoverMenuHandleState<M> {
 56    menu_builder: Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
 57    menu: Rc<RefCell<Option<Entity<M>>>>,
 58    on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
 59}
 60
 61impl<M: ManagedView> PopoverMenuHandle<M> {
 62    pub fn show(&self, window: &mut Window, cx: &mut App) {
 63        if let Some(state) = self.0.borrow().as_ref() {
 64            show_menu(
 65                &state.menu_builder,
 66                &state.menu,
 67                state.on_open.clone(),
 68                window,
 69                cx,
 70            );
 71        }
 72    }
 73
 74    pub fn hide(&self, cx: &mut App) {
 75        if let Some(state) = self.0.borrow().as_ref()
 76            && let Some(menu) = state.menu.borrow().as_ref()
 77        {
 78            menu.update(cx, |_, cx| cx.emit(DismissEvent));
 79        }
 80    }
 81
 82    pub fn toggle(&self, window: &mut Window, cx: &mut App) {
 83        if let Some(state) = self.0.borrow().as_ref() {
 84            if state.menu.borrow().is_some() {
 85                self.hide(cx);
 86            } else {
 87                self.show(window, cx);
 88            }
 89        }
 90    }
 91
 92    pub fn is_deployed(&self) -> bool {
 93        self.0
 94            .borrow()
 95            .as_ref()
 96            .is_some_and(|state| state.menu.borrow().as_ref().is_some())
 97    }
 98
 99    pub fn is_focused(&self, window: &Window, cx: &App) -> bool {
100        self.0.borrow().as_ref().is_some_and(|state| {
101            state
102                .menu
103                .borrow()
104                .as_ref()
105                .is_some_and(|model| model.focus_handle(cx).is_focused(window))
106        })
107    }
108
109    pub fn refresh_menu(
110        &self,
111        window: &mut Window,
112        cx: &mut App,
113        new_menu_builder: Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
114    ) {
115        let show_menu = if let Some(state) = self.0.borrow_mut().as_mut() {
116            state.menu_builder = new_menu_builder;
117            state.menu.borrow().is_some()
118        } else {
119            false
120        };
121
122        if show_menu {
123            self.show(window, cx);
124        }
125    }
126}
127
128pub struct PopoverMenu<M: ManagedView> {
129    id: ElementId,
130    child_builder: Option<
131        Box<
132            dyn FnOnce(
133                    Rc<RefCell<Option<Entity<M>>>>,
134                    Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
135                ) -> AnyElement
136                + 'static,
137        >,
138    >,
139    menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
140    anchor: Anchor,
141    attach: Option<Anchor>,
142    offset: Option<Point<Pixels>>,
143    trigger_handle: Option<PopoverMenuHandle<M>>,
144    on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
145    full_width: bool,
146}
147
148impl<M: ManagedView> PopoverMenu<M> {
149    /// Returns a new [`PopoverMenu`].
150    pub fn new(id: impl Into<ElementId>) -> Self {
151        Self {
152            id: id.into(),
153            child_builder: None,
154            menu_builder: None,
155            anchor: Anchor::TopLeft,
156            attach: None,
157            offset: None,
158            trigger_handle: None,
159            on_open: None,
160            full_width: false,
161        }
162    }
163
164    pub fn full_width(mut self, full_width: bool) -> Self {
165        self.full_width = full_width;
166        self
167    }
168
169    pub fn menu(
170        mut self,
171        f: impl Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static,
172    ) -> Self {
173        self.menu_builder = Some(Rc::new(f));
174        self
175    }
176
177    pub fn with_handle(mut self, handle: PopoverMenuHandle<M>) -> Self {
178        self.trigger_handle = Some(handle);
179        self
180    }
181
182    pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
183        let on_open = self.on_open.clone();
184        self.child_builder = Some(Box::new(move |menu, builder| {
185            let open = menu.borrow().is_some();
186            t.toggle_state(open)
187                .when_some(builder, |el, builder| {
188                    el.on_click(move |_event, window, cx| {
189                        show_menu(&builder, &menu, on_open.clone(), window, cx)
190                    })
191                })
192                .into_any_element()
193        }));
194        self
195    }
196
197    /// This method prevents the trigger button tooltip from being seen when the menu is open.
198    pub fn trigger_with_tooltip<T: PopoverTrigger + ButtonCommon>(
199        mut self,
200        t: T,
201        tooltip_builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
202    ) -> Self {
203        let on_open = self.on_open.clone();
204        self.child_builder = Some(Box::new(move |menu, builder| {
205            let open = menu.borrow().is_some();
206            t.toggle_state(open)
207                .when_some(builder, |el, builder| {
208                    el.on_click(move |_, window, cx| {
209                        show_menu(&builder, &menu, on_open.clone(), window, cx)
210                    })
211                    .when(!open, |t| {
212                        t.tooltip(move |window, cx| tooltip_builder(window, cx))
213                    })
214                })
215                .into_any_element()
216        }));
217        self
218    }
219
220    /// Defines which corner of the menu to anchor to the attachment point.
221    /// By default, it uses the cursor position. Also see the `attach` method.
222    pub fn anchor(mut self, anchor: Anchor) -> Self {
223        self.anchor = anchor;
224        self
225    }
226
227    /// Defines which corner of the handle to attach the menu's anchor to.
228    pub fn attach(mut self, attach: Anchor) -> Self {
229        self.attach = Some(attach);
230        self
231    }
232
233    /// Offsets the position of the content by that many pixels.
234    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
235        self.offset = Some(offset);
236        self
237    }
238
239    /// Attaches something upon opening the menu.
240    pub fn on_open(mut self, on_open: Rc<dyn Fn(&mut Window, &mut App)>) -> Self {
241        self.on_open = Some(on_open);
242        self
243    }
244
245    fn resolved_attach(&self) -> Anchor {
246        self.attach
247            .unwrap_or(self.attach.unwrap_or(match self.anchor {
248                Anchor::TopLeft => Anchor::BottomLeft,
249                Anchor::TopCenter => Anchor::BottomCenter,
250                Anchor::TopRight => Anchor::BottomRight,
251                Anchor::BottomLeft => Anchor::TopLeft,
252                Anchor::BottomCenter => Anchor::TopCenter,
253                Anchor::BottomRight => Anchor::TopRight,
254                Anchor::LeftCenter => Anchor::LeftCenter,
255                Anchor::RightCenter => Anchor::RightCenter,
256            }))
257    }
258
259    fn resolved_offset(&self, window: &mut Window) -> Point<Pixels> {
260        self.offset.unwrap_or_else(|| {
261            // Default offset = 4px padding + 1px border
262            let offset = rems_from_px(5.) * window.rem_size();
263            match self.anchor {
264                Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => {
265                    point(offset, px(0.))
266                }
267                Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => point(-offset, px(0.)),
268                Anchor::TopCenter | Anchor::BottomCenter => point(px(0.), px(0.)),
269            }
270        })
271    }
272}
273
274fn show_menu<M: ManagedView>(
275    builder: &Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
276    menu: &Rc<RefCell<Option<Entity<M>>>>,
277    on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
278    window: &mut Window,
279    cx: &mut App,
280) {
281    let previous_focus_handle = window.focused(cx);
282    let Some(new_menu) = (builder)(window, cx) else {
283        return;
284    };
285    let menu2 = menu.clone();
286
287    window
288        .subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| {
289            if modal.focus_handle(cx).contains_focused(window, cx)
290                && let Some(previous_focus_handle) = previous_focus_handle.as_ref()
291            {
292                window.focus(previous_focus_handle, cx);
293            }
294            *menu2.borrow_mut() = None;
295            window.refresh();
296        })
297        .detach();
298
299    // Since menus are rendered in a deferred fashion, their focus handles are
300    // not linked in the dispatch tree until after the deferred draw callback
301    // runs. We need to wait for that to happen before focusing it, so that
302    // calling `contains_focused` on the parent's focus handle returns `true`
303    // when the menu is focused. This prevents the pane's tab bar buttons from
304    // flickering when opening popover menus.
305    let focus_handle = new_menu.focus_handle(cx);
306    window.on_next_frame(move |window, _cx| {
307        window.on_next_frame(move |window, cx| {
308            window.focus(&focus_handle, cx);
309        });
310    });
311    *menu.borrow_mut() = Some(new_menu);
312    window.refresh();
313
314    if let Some(on_open) = on_open {
315        on_open(window, cx);
316    }
317}
318
319pub struct PopoverMenuElementState<M> {
320    menu: Rc<RefCell<Option<Entity<M>>>>,
321    child_bounds: Option<Bounds<Pixels>>,
322}
323
324impl<M> Clone for PopoverMenuElementState<M> {
325    fn clone(&self) -> Self {
326        Self {
327            menu: Rc::clone(&self.menu),
328            child_bounds: self.child_bounds,
329        }
330    }
331}
332
333impl<M> Default for PopoverMenuElementState<M> {
334    fn default() -> Self {
335        Self {
336            menu: Rc::default(),
337            child_bounds: None,
338        }
339    }
340}
341
342pub struct PopoverMenuFrameState<M: ManagedView> {
343    child_layout_id: Option<LayoutId>,
344    child_element: Option<AnyElement>,
345    menu_element: Option<AnyElement>,
346    menu_handle: Rc<RefCell<Option<Entity<M>>>>,
347}
348
349impl<M: ManagedView> Element for PopoverMenu<M> {
350    type RequestLayoutState = PopoverMenuFrameState<M>;
351    type PrepaintState = Option<HitboxId>;
352
353    fn id(&self) -> Option<ElementId> {
354        Some(self.id.clone())
355    }
356
357    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
358        None
359    }
360
361    fn request_layout(
362        &mut self,
363        global_id: Option<&GlobalElementId>,
364        _inspector_id: Option<&gpui::InspectorElementId>,
365        window: &mut Window,
366        cx: &mut App,
367    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
368        window.with_element_state(
369            global_id.unwrap(),
370            |element_state: Option<PopoverMenuElementState<M>>, window| {
371                let element_state = element_state.unwrap_or_default();
372                let mut menu_layout_id = None;
373
374                let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
375                    let offset = self.resolved_offset(window);
376                    let mut anchored = anchored()
377                        .snap_to_window_with_margin(px(8.))
378                        .anchor(self.anchor)
379                        .offset(offset);
380                    if let Some(child_bounds) = element_state.child_bounds {
381                        anchored =
382                            anchored.position(child_bounds.corner(self.resolved_attach()) + offset);
383                    }
384                    let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
385                        .with_priority(1)
386                        .into_any();
387
388                    menu_layout_id = Some(element.request_layout(window, cx));
389                    element
390                });
391
392                let mut child_element = self.child_builder.take().map(|child_builder| {
393                    (child_builder)(element_state.menu.clone(), self.menu_builder.clone())
394                });
395
396                if let Some(trigger_handle) = self.trigger_handle.take()
397                    && let Some(menu_builder) = self.menu_builder.clone()
398                {
399                    *trigger_handle.0.borrow_mut() = Some(PopoverMenuHandleState {
400                        menu_builder,
401                        menu: element_state.menu.clone(),
402                        on_open: self.on_open.clone(),
403                    });
404                }
405
406                let child_layout_id = child_element
407                    .as_mut()
408                    .map(|child_element| child_element.request_layout(window, cx));
409
410                let mut style = Style::default();
411                if self.full_width {
412                    style.size = size(relative(1.).into(), Length::Auto);
413                }
414
415                let layout_id = window.request_layout(
416                    style,
417                    menu_layout_id.into_iter().chain(child_layout_id),
418                    cx,
419                );
420
421                (
422                    (
423                        layout_id,
424                        PopoverMenuFrameState {
425                            child_element,
426                            child_layout_id,
427                            menu_element,
428                            menu_handle: element_state.menu.clone(),
429                        },
430                    ),
431                    element_state,
432                )
433            },
434        )
435    }
436
437    fn prepaint(
438        &mut self,
439        global_id: Option<&GlobalElementId>,
440        _inspector_id: Option<&gpui::InspectorElementId>,
441        _bounds: Bounds<Pixels>,
442        request_layout: &mut Self::RequestLayoutState,
443        window: &mut Window,
444        cx: &mut App,
445    ) -> Option<HitboxId> {
446        if let Some(child) = request_layout.child_element.as_mut() {
447            child.prepaint(window, cx);
448        }
449
450        if let Some(menu) = request_layout.menu_element.as_mut() {
451            menu.prepaint(window, cx);
452        }
453
454        request_layout.child_layout_id.map(|layout_id| {
455            let bounds = window.layout_bounds(layout_id);
456            window.with_element_state(global_id.unwrap(), |element_state, _cx| {
457                let mut element_state: PopoverMenuElementState<M> = element_state.unwrap();
458                element_state.child_bounds = Some(bounds);
459                ((), element_state)
460            });
461
462            window.insert_hitbox(bounds, HitboxBehavior::Normal).id
463        })
464    }
465
466    fn paint(
467        &mut self,
468        _id: Option<&GlobalElementId>,
469        _inspector_id: Option<&gpui::InspectorElementId>,
470        _: Bounds<gpui::Pixels>,
471        request_layout: &mut Self::RequestLayoutState,
472        child_hitbox: &mut Option<HitboxId>,
473        window: &mut Window,
474        cx: &mut App,
475    ) {
476        if let Some(mut child) = request_layout.child_element.take() {
477            child.paint(window, cx);
478        }
479
480        if let Some(mut menu) = request_layout.menu_element.take() {
481            menu.paint(window, cx);
482
483            if let Some(child_hitbox) = *child_hitbox {
484                let menu_handle = request_layout.menu_handle.clone();
485                // Mouse-downing outside the menu dismisses it, so we don't
486                // want a click on the toggle to re-open it.
487                window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
488                    if phase == DispatchPhase::Bubble && child_hitbox.is_hovered(window) {
489                        if let Some(menu) = menu_handle.borrow().as_ref() {
490                            menu.update(cx, |_, cx| {
491                                cx.emit(DismissEvent);
492                            });
493                        }
494                        cx.stop_propagation();
495                    }
496                })
497            }
498        }
499    }
500}
501
502impl<M: ManagedView> IntoElement for PopoverMenu<M> {
503    type Element = Self;
504
505    fn into_element(self) -> Self::Element {
506        self
507    }
508}