Skip to main content

gpui_component/
popover.rs

1use gpui::{
2    Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Div, ElementId,
3    FocusHandle, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels,
4    RenderOnce, Stateful, StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px,
5};
6use std::{rc::Rc, time::Duration};
7
8use crate::ThemeStyled as _;
9use crate::{
10    Selectable, StyledExt as _,
11    animation::ease_out_cubic,
12    styled::{popover_ring, popover_shadow},
13    v_flex,
14};
15use gpui_base::Popover as BasePopover;
16pub use gpui_base::PopoverState;
17
18pub(crate) fn init(_: &mut App) {}
19
20/// How long a dropdown takes to settle into place after it opens.
21///
22/// This is shadcn/ui's figure: its popup surfaces carry `animate-in`, whose
23/// duration is 150ms.
24const DROPDOWN_ENTER_DURATION: Duration = Duration::from_millis(150);
25
26/// Where a dropdown starts out, relative to where it comes to rest.
27///
28/// Negative is above, so the surface slides *down* out of the trigger's edge —
29/// what shadcn/ui expresses as `data-[side=bottom]:slide-in-from-top-2`. Its
30/// `2` is `0.5rem`, which is 8px at the default root size.
31const DROPDOWN_ENTER_OFFSET: Pixels = px(-8.);
32
33fn dropdown_positioner(bounds: Bounds<Pixels>) -> gpui_base::Positioner {
34    gpui_base::Positioner::side(bounds)
35        .placement(gpui_base::Placement::Bottom)
36        .align(gpui_base::Align::Start)
37        .offset(px(6.))
38        .margin(px(8.))
39}
40
41/// Positions a dropdown surface under its trigger and animates it in.
42///
43/// This is the shared open motion for Select, Combobox and DatePicker, modelled
44/// on shadcn/ui: over 150ms the surface fades up from nothing while sliding the
45/// last 8px out of the trigger's edge, on an ease-out curve so it decelerates
46/// into place.
47///
48/// `surface` must be the panel itself — the element carrying
49/// [`ThemeStyled::popover_style`] — and not a wrapper around it. GPUI takes a
50/// shadow's shape from the element it is set on, so a wrapper of a different
51/// size would throw the shadow out of register with the panel.
52///
53/// # Why the shadow is animated too
54///
55/// GPUI has no group compositing: `opacity` multiplies into each primitive's
56/// alpha separately rather than fading a composited subtree. A drop shadow is
57/// painted as a full blurred rect *under* the element — the shader only cuts the
58/// element out of `inset` shadows — so a translucent panel does not hide its own
59/// shadow, and mid-fade the shadow shows straight through the panel as a dark
60/// slab. Ramping the ink by the cube of the fade keeps it out of sight until the
61/// panel is opaque enough to cover it, and still lands on the resting shadow
62/// [`popover_shadow`] gives every other popup.
63///
64/// # Departures from shadcn
65///
66/// - shadcn also scales the surface up from 95% (`zoom-in-95`). GPUI has no
67///   element transform — only images and SVGs take a `TransformationMatrix` —
68///   so there is nothing to scale a subtree with, and the fade and slide carry
69///   the motion on their own.
70/// - There is no exit motion. A closing dropdown stops being rendered in the
71///   same frame its state flips, so playing one would mean keeping the surface
72///   mounted past the close, which is a change to how each of these components
73///   tracks `open`.
74/// - The slide always comes from above. [`gpui_base::Positioner`] resolves the
75///   side the surface actually lands on during layout and does not report it
76///   back, so a dropdown that flips above its trigger for want of room below
77///   slides the opposite way — 8px over 150ms, in the rare case where it
78///   happens.
79///
80/// Reduced motion needs no handling here: GPUI's animation element adopts the
81/// final value on the first frame when the system asks for it.
82pub(crate) fn dropdown_popup(
83    id: impl Into<ElementId>,
84    bounds: Bounds<Pixels>,
85    surface: impl IntoElement + Styled + 'static,
86    cx: &App,
87) -> gpui_base::Positioner {
88    let travel: f32 = DROPDOWN_ENTER_OFFSET.into();
89    // Read out here: the animation runs long after `cx` is gone.
90    let ring = popover_ring(cx);
91
92    dropdown_positioner(bounds).child(surface.with_animation(
93        id,
94        Animation::new(DROPDOWN_ENTER_DURATION).with_easing(ease_out_cubic),
95        move |surface, delta| {
96            surface
97                .top(px(travel * (1. - delta)))
98                .opacity(delta)
99                .shadow(popover_shadow(ring, delta * delta * delta))
100        },
101    ))
102}
103
104/// A popover element that can be triggered by a button or any other element.
105#[derive(IntoElement)]
106pub struct Popover {
107    id: ElementId,
108    style: StyleRefinement,
109    anchor: Anchor,
110    default_open: bool,
111    open: Option<bool>,
112    tracked_focus_handle: Option<FocusHandle>,
113    trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
114    content: Option<
115        Rc<
116            dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
117                + 'static,
118        >,
119    >,
120    children: Vec<AnyElement>,
121    /// Style for trigger element.
122    /// This is used for hotfix the trigger element style to support w_full.
123    trigger_style: Option<StyleRefinement>,
124    mouse_button: MouseButton,
125    appearance: bool,
126    overlay_closable: bool,
127    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
128}
129
130impl Popover {
131    /// Create a new Popover with `view` mode.
132    pub fn new(id: impl Into<ElementId>) -> Self {
133        Self {
134            id: id.into(),
135            style: StyleRefinement::default(),
136            anchor: Anchor::TopLeft,
137            trigger: None,
138            trigger_style: None,
139            content: None,
140            tracked_focus_handle: None,
141            children: vec![],
142            mouse_button: MouseButton::Left,
143            appearance: true,
144            overlay_closable: true,
145            default_open: false,
146            open: None,
147            on_open_change: None,
148        }
149    }
150
151    /// Set the anchor corner of the popover, default is [`Anchor::TopLeft`].
152    ///
153    /// Imagine the popover has a pointer tip (like a speech bubble's tail). The
154    /// anchor is where that tip sits relative to the trigger: `Anchor::TopLeft`
155    /// places it at the trigger's top-left corner, `Anchor::BottomRight` at the
156    /// bottom-right, and so on. The popover then hangs off that point.
157    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
158        self.anchor = anchor.into();
159        self
160    }
161
162    /// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
163    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
164        self.mouse_button = mouse_button;
165        self
166    }
167
168    /// Set the trigger element of the popover.
169    pub fn trigger<T>(mut self, trigger: T) -> Self
170    where
171        T: Selectable + IntoElement + 'static,
172    {
173        self.trigger = Some(Box::new(|is_open, _, _| {
174            let selected = trigger.is_selected();
175            trigger.selected(selected || is_open).into_any_element()
176        }));
177        self
178    }
179
180    /// Set the default open state of the popover, default is `false`.
181    ///
182    /// This is only used to initialize the open state of the popover.
183    ///
184    /// And please note that if you use the `open` method, this value will be ignored.
185    pub fn default_open(mut self, open: bool) -> Self {
186        self.default_open = open;
187        self
188    }
189
190    /// Force set the open state of the popover.
191    ///
192    /// If this is set, the popover will be controlled by this value.
193    ///
194    /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
195    pub fn open(mut self, open: bool) -> Self {
196        self.open = Some(open);
197        self
198    }
199
200    /// Add a callback to be called when the open state changes.
201    ///
202    /// The first `&bool` parameter is the **new open state**.
203    ///
204    /// This is useful when using the `open` method to control the popover state.
205    pub fn on_open_change<F>(mut self, callback: F) -> Self
206    where
207        F: Fn(&bool, &mut Window, &mut App) + 'static,
208    {
209        self.on_open_change = Some(Rc::new(callback));
210        self
211    }
212
213    /// Set the style for the trigger element.
214    pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
215        self.trigger_style = Some(style);
216        self
217    }
218
219    /// Set whether clicking outside the popover will dismiss it, default is `true`.
220    pub fn overlay_closable(mut self, closable: bool) -> Self {
221        self.overlay_closable = closable;
222        self
223    }
224
225    /// Set the content builder for content of the Popover.
226    ///
227    /// This callback will called every time on render the popover.
228    /// So, you should avoid creating new elements or entities in the content closure.
229    pub fn content<F, E>(mut self, content: F) -> Self
230    where
231        E: IntoElement,
232        F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
233    {
234        self.content = Some(Rc::new(move |state, window, cx| {
235            content(state, window, cx).into_any_element()
236        }));
237        self
238    }
239
240    /// Set whether the popover no style, default is `false`.
241    ///
242    /// If no style:
243    ///
244    /// - The popover will not have a bg, border, shadow, or padding.
245    /// - The click out of the popover will not dismiss it.
246    pub fn appearance(mut self, appearance: bool) -> Self {
247        self.appearance = appearance;
248        self
249    }
250
251    /// Bind the focus handle to receive focus when the popover is opened.
252    /// If you not set this, a new focus handle will be created for the popover to
253    ///
254    /// If popover is opened, the focus will be moved to the focus handle.
255    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
256        self.tracked_focus_handle = Some(handle.clone());
257        self
258    }
259}
260
261impl ParentElement for Popover {
262    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
263        self.children.extend(elements);
264    }
265}
266
267impl Styled for Popover {
268    fn style(&mut self) -> &mut StyleRefinement {
269        &mut self.style
270    }
271}
272
273impl Popover {
274    pub(crate) fn render_popover_content(
275        anchor: Anchor,
276        appearance: bool,
277        _: &mut Window,
278        cx: &mut App,
279    ) -> Stateful<Div> {
280        v_flex()
281            .id("content")
282            .occlude()
283            .tab_group()
284            .when(appearance, |this| this.popover_style(cx).p_3())
285            .map(|this| match anchor {
286                Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
287                Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
288                Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
289            })
290    }
291}
292
293impl RenderOnce for Popover {
294    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
295        let anchor = self.anchor;
296        let appearance = self.appearance;
297        let style = self.style;
298        let children = self.children;
299        let content = self.content;
300
301        BasePopover::new(self.id)
302            .anchor(self.anchor)
303            .mouse_button(self.mouse_button)
304            .default_open(self.default_open)
305            .overlay_closable(self.overlay_closable)
306            .content(move |state, window, cx| {
307                Self::render_popover_content(anchor, appearance, window, cx)
308                    .when_some(content, |this, content| {
309                        this.child((content)(state, window, cx))
310                    })
311                    .children(children)
312                    .refine_style(&style)
313            })
314            .when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
315            .when_some(self.open, |this, open| this.open(open))
316            .when_some(self.tracked_focus_handle, |this, handle| {
317                this.track_focus(&handle)
318            })
319            .when_some(self.on_open_change, |this, callback| {
320                this.on_open_change(move |open, window, cx| callback(open, window, cx))
321            })
322            .into_any_element()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::{button::Button, theme::Theme};
330    use gpui::{Bounds, Context, MouseButton, Point, Render, div, point, px, size};
331    use gpui_base::Popup as BasePopup;
332    use std::{cell::RefCell, rc::Rc};
333
334    #[test]
335    fn test_popover_builder_chaining() {
336        let popover = Popover::new("test")
337            .anchor(Anchor::BottomCenter)
338            .mouse_button(MouseButton::Right)
339            .default_open(true)
340            .appearance(false)
341            .overlay_closable(false);
342
343        assert_eq!(popover.anchor, Anchor::BottomCenter);
344        assert_eq!(popover.mouse_button, MouseButton::Right);
345        assert!(popover.default_open);
346        assert!(!popover.appearance);
347        assert!(!popover.overlay_closable);
348    }
349
350    #[test]
351    fn test_resolved_corner_top_positions() {
352        use gpui::px;
353
354        let bounds = Bounds {
355            origin: Point {
356                x: px(100.),
357                y: px(100.),
358            },
359            size: gpui::Size {
360                width: px(200.),
361                height: px(50.),
362            },
363        };
364
365        let pos = BasePopup::resolved_corner(Anchor::TopLeft, bounds);
366        assert_eq!(pos.x, px(100.));
367        assert_eq!(pos.y, px(100.));
368
369        let pos = BasePopup::resolved_corner(Anchor::TopCenter, bounds);
370        assert_eq!(pos.x, px(200.));
371        assert_eq!(pos.y, px(100.));
372
373        let pos = BasePopup::resolved_corner(Anchor::TopRight, bounds);
374        assert_eq!(pos.x, px(300.));
375        assert_eq!(pos.y, px(100.));
376
377        let pos = BasePopup::resolved_corner(Anchor::BottomLeft, bounds);
378        assert_eq!(pos.x, px(100.));
379        assert_eq!(pos.y, px(50.));
380
381        let pos = BasePopup::resolved_corner(Anchor::BottomCenter, bounds);
382        assert_eq!(pos.x, px(200.));
383        assert_eq!(pos.y, px(50.));
384
385        let pos = BasePopup::resolved_corner(Anchor::BottomRight, bounds);
386        assert_eq!(pos.x, px(300.));
387        assert_eq!(pos.y, px(50.));
388    }
389
390    struct PopoverHarness {
391        changes: Rc<RefCell<Vec<bool>>>,
392    }
393
394    impl Render for PopoverHarness {
395        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
396            let changes = self.changes.clone();
397            Popover::new("runtime-popover")
398                .trigger(Button::new("runtime-trigger").label("Open").size(px(100.)))
399                .content(|_, _, _| {
400                    div()
401                        .debug_selector(|| "runtime-popover-content".into())
402                        .size(px(40.))
403                })
404                .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
405        }
406    }
407
408    #[gpui::test]
409    fn pointer_open_and_outside_dismiss_use_the_base_popup_host(cx: &mut gpui::TestAppContext) {
410        cx.update(|cx| {
411            gpui_base::GlobalState::init(cx);
412            cx.set_global(Theme::default());
413            init(cx);
414        });
415
416        let changes = Rc::new(RefCell::new(Vec::new()));
417        let (_, cx) = cx.add_window_view({
418            let changes = changes.clone();
419            move |_, _| PopoverHarness { changes }
420        });
421        cx.update(|window, cx| window.draw(cx).clear(cx));
422
423        cx.simulate_click(point(px(20.), px(20.)), Default::default());
424        cx.update(|window, cx| window.draw(cx).clear(cx));
425        assert!(cx.debug_bounds("runtime-popover-content").is_some());
426
427        cx.simulate_click(point(px(300.), px(300.)), Default::default());
428        cx.update(|window, cx| window.draw(cx).clear(cx));
429        assert!(cx.debug_bounds("runtime-popover-content").is_none());
430        // A change callback reports state transitions, not redundant dismissal
431        // requests. The base host may see both paths, but only the first closes.
432        assert_eq!(&*changes.borrow(), &[true, false]);
433    }
434
435    struct DefaultOpenHarness;
436
437    impl Render for DefaultOpenHarness {
438        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
439            Popover::new("default-open-popover")
440                .default_open(true)
441                .trigger(Button::new("default-open-trigger").label("Open"))
442                .child(
443                    div()
444                        .debug_selector(|| "default-open-content".into())
445                        .size(px(40.)),
446                )
447        }
448    }
449
450    #[gpui::test]
451    fn default_open_is_forwarded_to_the_base_popover(cx: &mut gpui::TestAppContext) {
452        cx.update(|cx| {
453            gpui_base::GlobalState::init(cx);
454            cx.set_global(Theme::default());
455            init(cx);
456        });
457        let (_, cx) = cx.add_window_view(|_, _| DefaultOpenHarness);
458        cx.update(|window, cx| window.draw(cx).clear(cx));
459        cx.update(|window, cx| window.draw(cx).clear(cx));
460        assert!(cx.debug_bounds("default-open-content").is_some());
461    }
462
463    struct Harness {
464        open: bool,
465    }
466
467    impl Render for Harness {
468        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
469            div().size_full().when(self.open, |this| {
470                this.child(dropdown_popup(
471                    "dropdown",
472                    Bounds::new(point(px(0.), px(100.)), size(px(120.), px(30.))),
473                    div().debug_selector(|| "surface".into()).size(px(50.)),
474                    cx,
475                ))
476            })
477        }
478    }
479
480    /// A dropdown that reused one animation key across opens would play its
481    /// enter motion the first time and then appear already settled on every
482    /// open after that. That is invisible in any single frame and easy to
483    /// reintroduce by giving the animation a constant id, so it is pinned here.
484    #[gpui::test]
485    fn the_enter_motion_starts_over_every_time_the_dropdown_opens(cx: &mut gpui::TestAppContext) {
486        cx.update(crate::init);
487        let (view, window) = cx.add_window_view(|_, _| Harness { open: true });
488
489        window.update(|window, cx| window.draw(cx).clear(cx));
490        let opening = window.debug_bounds("surface").unwrap().origin;
491
492        // The animation runs off the wall clock, so settling is waited out
493        // rather than stepped. Several times the duration leaves room for a
494        // loaded machine.
495        std::thread::sleep(DROPDOWN_ENTER_DURATION * 4);
496        window.update(|window, cx| window.draw(cx).clear(cx));
497        let settled = window.debug_bounds("surface").unwrap().origin;
498
499        assert!(
500            opening.y < settled.y,
501            "the surface should slide down into place, from {opening:?} to {settled:?}",
502        );
503
504        for open in [false, true] {
505            window.update(|window, cx| {
506                view.update(cx, |this, cx| {
507                    this.open = open;
508                    cx.notify();
509                });
510                window.draw(cx).clear(cx);
511            });
512        }
513
514        let reopening = window.debug_bounds("surface").unwrap().origin;
515        assert!(
516            reopening.y < settled.y,
517            "reopening should start the motion over at {opening:?} rather than showing a \
518             settled surface, but the first frame was already at {reopening:?}",
519        );
520    }
521}