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, PathBuilder,
4    Pixels, Point, RenderOnce, Stateful, StyleRefinement, Styled, Window, canvas, point,
5    prelude::FluentBuilder as _, px,
6};
7use std::{cell::Cell, rc::Rc, time::Duration};
8
9use crate::{ActiveTheme as _, ThemeStyled as _};
10use crate::{
11    Selectable, StyledExt as _,
12    animation::ease_out_cubic,
13    styled::{popover_ring, popover_shadow},
14    v_flex,
15};
16use gpui_base::Placement;
17use gpui_base::Popover as BasePopover;
18pub use gpui_base::PopoverState;
19
20pub(crate) fn init(_: &mut App) {}
21
22/// How long a dropdown takes to settle into place after it opens.
23///
24/// This is shadcn/ui's figure: its popup surfaces carry `animate-in`, whose
25/// duration is 150ms.
26const DROPDOWN_ENTER_DURATION: Duration = Duration::from_millis(150);
27
28/// Where a dropdown starts out, relative to where it comes to rest.
29///
30/// Negative is above, so the surface slides *down* out of the trigger's edge —
31/// what shadcn/ui expresses as `data-[side=bottom]:slide-in-from-top-2`. Its
32/// `2` is `0.5rem`, which is 8px at the default root size.
33const DROPDOWN_ENTER_OFFSET: Pixels = px(-8.);
34
35fn dropdown_positioner(bounds: Bounds<Pixels>) -> gpui_base::Positioner {
36    gpui_base::Positioner::side(bounds)
37        .placement(gpui_base::Placement::Bottom)
38        .align(gpui_base::Align::Start)
39        .offset(px(6.))
40        .margin(px(8.))
41}
42
43/// Positions a dropdown surface under its trigger and animates it in.
44///
45/// This is the shared open motion for Select, Combobox and DatePicker, modelled
46/// on shadcn/ui: over 150ms the surface fades up from nothing while sliding the
47/// last 8px out of the trigger's edge, on an ease-out curve so it decelerates
48/// into place.
49///
50/// `surface` must be the panel itself — the element carrying
51/// [`ThemeStyled::popover_style`] — and not a wrapper around it. GPUI takes a
52/// shadow's shape from the element it is set on, so a wrapper of a different
53/// size would throw the shadow out of register with the panel.
54///
55/// # Why the shadow is animated too
56///
57/// GPUI has no group compositing: `opacity` multiplies into each primitive's
58/// alpha separately rather than fading a composited subtree. A drop shadow is
59/// painted as a full blurred rect *under* the element — the shader only cuts the
60/// element out of `inset` shadows — so a translucent panel does not hide its own
61/// shadow, and mid-fade the shadow shows straight through the panel as a dark
62/// slab. Ramping the ink by the cube of the fade keeps it out of sight until the
63/// panel is opaque enough to cover it, and still lands on the resting shadow
64/// [`popover_shadow`] gives every other popup.
65///
66/// # Departures from shadcn
67///
68/// - shadcn also scales the surface up from 95% (`zoom-in-95`). GPUI has no
69///   element transform — only images and SVGs take a `TransformationMatrix` —
70///   so there is nothing to scale a subtree with, and the fade and slide carry
71///   the motion on their own.
72/// - There is no exit motion. A closing dropdown stops being rendered in the
73///   same frame its state flips, so playing one would mean keeping the surface
74///   mounted past the close, which is a change to how each of these components
75///   tracks `open`.
76/// - The slide always comes from above. [`gpui_base::Positioner`] resolves the
77///   side the surface actually lands on during layout and does not report it
78///   back, so a dropdown that flips above its trigger for want of room below
79///   slides the opposite way — 8px over 150ms, in the rare case where it
80///   happens.
81///
82/// Reduced motion needs no handling here: GPUI's animation element adopts the
83/// final value on the first frame when the system asks for it.
84pub(crate) fn dropdown_popup(
85    id: impl Into<ElementId>,
86    bounds: Bounds<Pixels>,
87    surface: impl IntoElement + Styled + 'static,
88    cx: &App,
89) -> gpui_base::Positioner {
90    let travel: f32 = DROPDOWN_ENTER_OFFSET.into();
91    // Read out here: the animation runs long after `cx` is gone.
92    let ring = popover_ring(cx);
93
94    dropdown_positioner(bounds).child(surface.with_animation(
95        id,
96        Animation::new(DROPDOWN_ENTER_DURATION).with_easing(ease_out_cubic),
97        move |surface, delta| {
98            surface
99                .top(px(travel * (1. - delta)))
100                .opacity(delta)
101                .shadow(popover_shadow(ring, delta * delta * delta))
102        },
103    ))
104}
105
106/// A popover element that can be triggered by a button or any other element.
107#[derive(IntoElement)]
108pub struct Popover {
109    id: ElementId,
110    style: StyleRefinement,
111    anchor: Anchor,
112    offset: Option<Pixels>,
113    arrow: bool,
114    default_open: bool,
115    open: Option<bool>,
116    tracked_focus_handle: Option<FocusHandle>,
117    trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
118    content: Option<
119        Rc<
120            dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
121                + 'static,
122        >,
123    >,
124    children: Vec<AnyElement>,
125    /// Style for the trigger container, the element laid out in the parent
126    /// and measured to anchor the popup.
127    trigger_style: Option<StyleRefinement>,
128    mouse_button: MouseButton,
129    appearance: bool,
130    overlay_closable: bool,
131    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
132}
133
134impl Popover {
135    /// Create a new Popover with `view` mode.
136    pub fn new(id: impl Into<ElementId>) -> Self {
137        Self {
138            id: id.into(),
139            style: StyleRefinement::default(),
140            anchor: Anchor::TopLeft,
141            offset: None,
142            arrow: false,
143            trigger: None,
144            trigger_style: None,
145            content: None,
146            tracked_focus_handle: None,
147            children: vec![],
148            mouse_button: MouseButton::Left,
149            appearance: true,
150            overlay_closable: true,
151            default_open: false,
152            open: None,
153            on_open_change: None,
154        }
155    }
156
157    /// Set the anchor corner of the popover, default is [`Anchor::TopLeft`].
158    ///
159    /// This names the popover's own anchor, not a corner of the trigger.
160    /// `TopLeft` opens below the trigger, left-aligned; `BottomRight` opens
161    /// above it, right-aligned. Legacy anchoring clamps without flipping.
162    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
163        self.anchor = anchor.into();
164        self
165    }
166
167    /// Gap from the trigger to the surface (or arrow tip), default 0.25rem.
168    /// Preserves the anchor and does not enable automatic flipping.
169    pub fn offset(mut self, offset: impl Into<Pixels>) -> Self {
170        self.offset = Some(offset.into());
171        self
172    }
173
174    /// Show an arrow pointing toward the trigger. Default is `false`.
175    /// Follows the anchor, with its base inset to avoid rounded corners.
176    /// Uses the surface background, falling back to the theme's popover color.
177    pub fn arrow(mut self, arrow: bool) -> Self {
178        self.arrow = arrow;
179        self
180    }
181
182    /// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
183    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
184        self.mouse_button = mouse_button;
185        self
186    }
187
188    /// Set the trigger element of the popover.
189    pub fn trigger<T>(mut self, trigger: T) -> Self
190    where
191        T: Selectable + IntoElement + 'static,
192    {
193        self.trigger = Some(Box::new(|is_open, _, _| {
194            let open = trigger.is_open();
195            trigger.open(open || is_open).into_any_element()
196        }));
197        self
198    }
199
200    /// Set the default open state of the popover, default is `false`.
201    ///
202    /// This is only used to initialize the open state of the popover.
203    ///
204    /// And please note that if you use the `open` method, this value will be ignored.
205    pub fn default_open(mut self, open: bool) -> Self {
206        self.default_open = open;
207        self
208    }
209
210    /// Force set the open state of the popover.
211    ///
212    /// If this is set, the popover will be controlled by this value.
213    ///
214    /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
215    pub fn open(mut self, open: bool) -> Self {
216        self.open = Some(open);
217        self
218    }
219
220    /// Add a callback to be called when the open state changes.
221    ///
222    /// The first `&bool` parameter is the **new open state**.
223    ///
224    /// This is useful when using the `open` method to control the popover state.
225    pub fn on_open_change<F>(mut self, callback: F) -> Self
226    where
227        F: Fn(&bool, &mut Window, &mut App) + 'static,
228    {
229        self.on_open_change = Some(Rc::new(callback));
230        self
231    }
232
233    /// Set the style for the trigger container.
234    ///
235    /// The container is the element laid out in the parent and measured to
236    /// anchor the popup, so this is where `w_full` or `flex_1` must go for the
237    /// trigger to fill its slot.
238    pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
239        self.trigger_style = Some(style);
240        self
241    }
242
243    /// Set whether clicking outside the popover will dismiss it, default is `true`.
244    pub fn overlay_closable(mut self, closable: bool) -> Self {
245        self.overlay_closable = closable;
246        self
247    }
248
249    /// Set the content builder for content of the Popover.
250    ///
251    /// This callback will called every time on render the popover.
252    /// So, you should avoid creating new elements or entities in the content closure.
253    pub fn content<F, E>(mut self, content: F) -> Self
254    where
255        E: IntoElement,
256        F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
257    {
258        self.content = Some(Rc::new(move |state, window, cx| {
259            content(state, window, cx).into_any_element()
260        }));
261        self
262    }
263
264    /// Set whether the popover no style, default is `false`.
265    ///
266    /// If no style:
267    ///
268    /// - The popover will not have a bg, border, shadow, or padding.
269    /// - The click out of the popover will not dismiss it.
270    pub fn appearance(mut self, appearance: bool) -> Self {
271        self.appearance = appearance;
272        self
273    }
274
275    /// Bind the focus handle to receive focus when the popover is opened.
276    /// If you not set this, a new focus handle will be created for the popover to
277    ///
278    /// If popover is opened, the focus will be moved to the focus handle.
279    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
280        self.tracked_focus_handle = Some(handle.clone());
281        self
282    }
283}
284
285impl ParentElement for Popover {
286    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
287        self.children.extend(elements);
288    }
289}
290
291impl Styled for Popover {
292    fn style(&mut self) -> &mut StyleRefinement {
293        &mut self.style
294    }
295}
296
297impl Popover {
298    pub(crate) fn render_popover_content(
299        anchor: Anchor,
300        appearance: bool,
301        _: &mut Window,
302        cx: &mut App,
303    ) -> Stateful<Div> {
304        v_flex()
305            .id("content")
306            .occlude()
307            .tab_group()
308            .when(appearance, |this| this.popover_style(cx).p_3())
309            .map(|this| match anchor {
310                Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
311                Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
312                Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
313            })
314    }
315}
316
317impl RenderOnce for Popover {
318    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
319        let anchor = self.anchor;
320        let arrow_size = if self.arrow {
321            window.rem_size() * 0.375
322        } else {
323            px(0.)
324        };
325        let offset = self.offset.unwrap_or(window.rem_size() * 0.25) + arrow_size;
326        let resolved = Rc::new(Cell::new(None));
327        let arrow_position = resolved.clone();
328        let arrow = self.arrow;
329        let background = self
330            .style
331            .background
332            .as_ref()
333            .and_then(gpui::Fill::color)
334            .unwrap_or_else(|| cx.theme().popover.into());
335        let ring = popover_ring(cx);
336        let radius = cx.theme().radius;
337        let appearance = self.appearance;
338        let style = self.style;
339        let children = self.children;
340        let content = self.content;
341        let trigger_style = self.trigger_style;
342
343        BasePopover::new(self.id)
344            .anchor(self.anchor)
345            .offset(offset)
346            .on_position(move |position, trigger| resolved.set(Some((position, trigger))))
347            .mouse_button(self.mouse_button)
348            .default_open(self.default_open)
349            .overlay_closable(self.overlay_closable)
350            .content(move |state, window, cx| {
351                v_flex()
352                    .id("content")
353                    .occlude()
354                    .tab_group()
355                    .when(appearance, |this| this.popover_style(cx).p_3())
356                    .when_some(content, |this, content| {
357                        this.child((content)(state, window, cx))
358                    })
359                    .children(children)
360                    .refine_style(&style)
361                    .when(arrow, |this| {
362                        this.child(
363                            canvas(
364                                |_, _, _| {},
365                                move |bounds, _, window, _| {
366                                    let Some((_, trigger)) = arrow_position.get() else {
367                                        return;
368                                    };
369                                    let (side, target) = arrow_anchor(anchor, trigger);
370                                    let trigger = Bounds::new(target, gpui::size(px(0.), px(0.)));
371                                    let points =
372                                        arrow_points(bounds, trigger, side, arrow_size, radius);
373                                    let mut fill = PathBuilder::fill();
374                                    fill.move_to(points[0]);
375                                    fill.line_to(points[1]);
376                                    fill.line_to(points[2]);
377                                    fill.close();
378                                    if let Ok(path) = fill.build() {
379                                        window.paint_path(path, background);
380                                    }
381                                    // The triangle ends exactly at the surface edge.
382                                    // Cover the ring and the antialiased base on both
383                                    // sides of that edge before drawing its two slopes.
384                                    window.paint_quad(gpui::fill(
385                                        arrow_join_bounds(points, side, px(1.)),
386                                        background,
387                                    ));
388                                    if appearance {
389                                        let mut outline = PathBuilder::stroke(px(1.));
390                                        outline.move_to(points[0]);
391                                        outline.line_to(points[1]);
392                                        outline.line_to(points[2]);
393                                        if let Ok(path) = outline.build() {
394                                            window.paint_path(path, ring);
395                                        }
396                                    }
397                                },
398                            )
399                            .absolute()
400                            .inset_0()
401                            .size_full(),
402                        )
403                    })
404            })
405            .when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
406            .when_some(trigger_style, |this, style| this.refine_style(&style))
407            .when_some(self.open, |this, open| this.open(open))
408            .when_some(self.tracked_focus_handle, |this, handle| {
409                this.track_focus(&handle)
410            })
411            .when_some(self.on_open_change, |this, callback| {
412                this.on_open_change(move |open, window, cx| callback(open, window, cx))
413            })
414            .into_any_element()
415    }
416}
417
418/// The arrow follows the named anchor instead of always aiming at trigger center.
419fn arrow_anchor(anchor: Anchor, trigger: Bounds<Pixels>) -> (Placement, Point<Pixels>) {
420    match anchor {
421        Anchor::TopLeft => (Placement::Bottom, trigger.bottom_left()),
422        Anchor::TopCenter => (Placement::Bottom, trigger.bottom_center()),
423        Anchor::TopRight => (Placement::Bottom, trigger.bottom_right()),
424        Anchor::BottomLeft => (Placement::Top, trigger.origin),
425        Anchor::BottomCenter => (Placement::Top, trigger.top_center()),
426        Anchor::BottomRight => (Placement::Top, trigger.top_right()),
427        Anchor::LeftCenter => (Placement::Right, trigger.right_center()),
428        Anchor::RightCenter => (Placement::Left, trigger.left_center()),
429    }
430}
431
432/// Clamp the arrow base clear of rounded corners while aiming at the trigger.
433fn arrow_points(
434    surface: Bounds<Pixels>,
435    trigger: Bounds<Pixels>,
436    side: Placement,
437    depth: Pixels,
438    radius: Pixels,
439) -> [Point<Pixels>; 3] {
440    let horizontal = side.is_horizontal();
441    let (start, end, target) = if horizontal {
442        (surface.top(), surface.bottom(), trigger.center().y)
443    } else {
444        (surface.left(), surface.right(), trigger.center().x)
445    };
446    let half = depth.min((end - start) * 0.5);
447    let inset = (radius + half).min((end - start) * 0.5);
448    let center = target.clamp(start + inset, end - inset);
449    match side {
450        Placement::Bottom => [
451            point(center - half, surface.top()),
452            point(center, surface.top() - depth),
453            point(center + half, surface.top()),
454        ],
455        Placement::Top => [
456            point(center - half, surface.bottom()),
457            point(center, surface.bottom() + depth),
458            point(center + half, surface.bottom()),
459        ],
460        Placement::Right => [
461            point(surface.left(), center - half),
462            point(surface.left() - depth, center),
463            point(surface.left(), center + half),
464        ],
465        Placement::Left => [
466            point(surface.right(), center - half),
467            point(surface.right() + depth, center),
468            point(surface.right(), center + half),
469        ],
470    }
471}
472
473fn arrow_join_bounds(
474    points: [Point<Pixels>; 3],
475    side: Placement,
476    stroke: Pixels,
477) -> Bounds<Pixels> {
478    // Inset by the stroke width so the patch remains inside the triangle's
479    // slopes at the outer edge of the ring, including on very small surfaces.
480    if side.is_horizontal() {
481        let inset = stroke.min((points[2].y - points[0].y) * 0.5);
482        Bounds::from_corners(
483            point(points[0].x - stroke, points[0].y + inset),
484            point(points[2].x + stroke, points[2].y - inset),
485        )
486    } else {
487        let inset = stroke.min((points[2].x - points[0].x) * 0.5);
488        Bounds::from_corners(
489            point(points[0].x + inset, points[0].y - stroke),
490            point(points[2].x - inset, points[2].y + stroke),
491        )
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::{button::Button, h_flex, theme::Theme};
499    use gpui::{Bounds, Context, MouseButton, Point, Render, div, point, px, size};
500    use gpui_base::Popup as BasePopup;
501    use std::{cell::RefCell, rc::Rc};
502
503    #[test]
504    fn test_popover_builder_chaining() {
505        let popover = Popover::new("test")
506            .anchor(Anchor::BottomCenter)
507            .mouse_button(MouseButton::Right)
508            .default_open(true)
509            .appearance(false)
510            .overlay_closable(false);
511
512        assert_eq!(popover.anchor, Anchor::BottomCenter);
513        assert_eq!(popover.mouse_button, MouseButton::Right);
514        assert!(popover.default_open);
515        assert!(!popover.appearance);
516        assert!(!popover.overlay_closable);
517    }
518
519    #[test]
520    fn test_resolved_corner_top_positions() {
521        use gpui::px;
522
523        let bounds = Bounds {
524            origin: Point {
525                x: px(100.),
526                y: px(100.),
527            },
528            size: gpui::Size {
529                width: px(200.),
530                height: px(50.),
531            },
532        };
533
534        let pos = BasePopup::resolved_corner(Anchor::TopLeft, bounds);
535        assert_eq!(pos.x, px(100.));
536        assert_eq!(pos.y, px(100.));
537
538        let pos = BasePopup::resolved_corner(Anchor::TopCenter, bounds);
539        assert_eq!(pos.x, px(200.));
540        assert_eq!(pos.y, px(100.));
541
542        let pos = BasePopup::resolved_corner(Anchor::TopRight, bounds);
543        assert_eq!(pos.x, px(300.));
544        assert_eq!(pos.y, px(100.));
545
546        let pos = BasePopup::resolved_corner(Anchor::BottomLeft, bounds);
547        assert_eq!(pos.x, px(100.));
548        assert_eq!(pos.y, px(50.));
549
550        let pos = BasePopup::resolved_corner(Anchor::BottomCenter, bounds);
551        assert_eq!(pos.x, px(200.));
552        assert_eq!(pos.y, px(50.));
553
554        let pos = BasePopup::resolved_corner(Anchor::BottomRight, bounds);
555        assert_eq!(pos.x, px(300.));
556        assert_eq!(pos.y, px(50.));
557    }
558
559    struct PopoverHarness {
560        changes: Rc<RefCell<Vec<bool>>>,
561    }
562
563    struct AnchorHarness {
564        anchor: Anchor,
565        offset: Option<Pixels>,
566        origin: Point<Pixels>,
567        arrow: bool,
568    }
569
570    impl Render for AnchorHarness {
571        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
572            div().size_full().child(
573                div()
574                    .absolute()
575                    .left(self.origin.x)
576                    .top(self.origin.y)
577                    .child(
578                        Popover::new("positioned-popover")
579                            .default_open(true)
580                            .appearance(false)
581                            .arrow(self.arrow)
582                            .anchor(self.anchor)
583                            .when_some(self.offset, |this, gap| this.offset(gap))
584                            .trigger(Button::new("positioned-trigger").size(px(40.)))
585                            .child(
586                                div()
587                                    .debug_selector(|| "positioned-content".into())
588                                    .size(px(60.)),
589                            ),
590                    ),
591            )
592        }
593    }
594
595    #[gpui::test]
596    fn anchor_and_offset_position_the_surface_on_each_trigger_edge(cx: &mut gpui::TestAppContext) {
597        cx.update(crate::init);
598        let (view, window) = cx.add_window_view(|_, _| AnchorHarness {
599            anchor: Anchor::TopLeft,
600            offset: None,
601            origin: point(px(200.), px(200.)),
602            arrow: false,
603        });
604        window.update(|window, cx| window.draw(cx).clear(cx));
605        window.update(|window, cx| window.draw(cx).clear(cx));
606        // Legacy TopLeft means below the trigger, including the default 0.25rem gap.
607        let legacy = window.debug_bounds("positioned-content").unwrap();
608        assert_eq!(legacy.left(), px(200.));
609        assert_eq!(legacy.top(), px(244.));
610
611        for (side, x, y) in [
612            (Anchor::BottomLeft, 200., 128.),
613            (Anchor::BottomCenter, 190., 128.),
614            (Anchor::BottomRight, 180., 128.),
615            (Anchor::TopLeft, 200., 252.),
616            (Anchor::TopCenter, 190., 252.),
617            (Anchor::TopRight, 180., 252.),
618            (Anchor::RightCenter, 128., 190.),
619            (Anchor::LeftCenter, 252., 190.),
620        ] {
621            window.update(|window, cx| {
622                view.update(cx, |view, cx| {
623                    view.anchor = side;
624                    view.offset = Some(px(12.));
625                    cx.notify();
626                });
627                window.draw(cx).clear(cx);
628            });
629            assert_eq!(
630                window.debug_bounds("positioned-content").unwrap().origin,
631                point(px(x), px(y)),
632                "{side:?}"
633            );
634        }
635        // Current-frame trigger bounds must be used after the owner moves.
636        window.update(|window, cx| {
637            view.update(cx, |view, cx| {
638                view.origin = point(px(260.), px(240.));
639                cx.notify();
640            });
641            window.draw(cx).clear(cx);
642        });
643        assert_eq!(
644            window.debug_bounds("positioned-content").unwrap().origin,
645            point(px(312.), px(230.))
646        );
647    }
648
649    struct TriggerStyleHarness {
650        styled: bool,
651    }
652
653    impl Render for TriggerStyleHarness {
654        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
655            // Keep the trigger clear of the popup's window margin so the
656            // content origin reflects only the trigger container's bounds.
657            div().size_full().child(
658                h_flex()
659                    .absolute()
660                    .left(px(100.))
661                    .top(px(100.))
662                    .w(px(200.))
663                    .child(
664                        Popover::new("trigger-style-popover")
665                            .default_open(true)
666                            .appearance(false)
667                            .offset(px(0.))
668                            .anchor(Anchor::TopRight)
669                            .when(self.styled, |this| {
670                                this.trigger_style(StyleRefinement::default().w_full())
671                            })
672                            .trigger(Button::new("styled-trigger").size(px(40.)))
673                            .child(
674                                div()
675                                    .debug_selector(|| "styled-content".into())
676                                    .size(px(20.)),
677                            ),
678                    ),
679            )
680        }
681    }
682
683    /// `trigger_style` styles the container the parent lays out and the popup
684    /// is anchored to. Applied to anything nested inside it, `w_full` would
685    /// resolve against a container that only wraps its content.
686    #[gpui::test]
687    fn trigger_style_is_applied_to_the_trigger_container(cx: &mut gpui::TestAppContext) {
688        cx.update(crate::init);
689        let (view, window) = cx.add_window_view(|_, _| TriggerStyleHarness { styled: false });
690        window.update(|window, cx| window.draw(cx).clear(cx));
691        window.update(|window, cx| window.draw(cx).clear(cx));
692        // Unstyled: the container wraps the 40px trigger, so the content's right
693        // edge meets the trigger's right edge at 140px.
694        assert_eq!(
695            window.debug_bounds("styled-content").unwrap().origin,
696            point(px(120.), px(140.))
697        );
698
699        window.update(|window, cx| {
700            view.update(cx, |view, cx| {
701                view.styled = true;
702                cx.notify();
703            });
704            window.draw(cx).clear(cx);
705        });
706        window.update(|window, cx| window.draw(cx).clear(cx));
707        // `w_full` stretches the container across the 200px row, and the popup
708        // follows the container's right edge at 300px.
709        assert_eq!(
710            window.debug_bounds("styled-content").unwrap().origin,
711            point(px(280.), px(140.))
712        );
713    }
714
715    #[gpui::test]
716    fn arrow_reserves_space_without_changing_anchor_alignment(cx: &mut gpui::TestAppContext) {
717        cx.update(crate::init);
718        let (view, window) = cx.add_window_view(|_, _| AnchorHarness {
719            anchor: Anchor::TopLeft,
720            offset: Some(px(12.)),
721            origin: point(px(200.), px(8.)),
722            arrow: true,
723        });
724        window.update(|window, cx| window.draw(cx).clear(cx));
725        window.update(|window, cx| window.draw(cx).clear(cx));
726        // Bottom edge 48 + tip gap 12 + arrow depth 6.
727        assert_eq!(
728            window.debug_bounds("positioned-content").unwrap().origin,
729            point(px(200.), px(66.))
730        );
731        window.update(|window, cx| {
732            view.update(cx, |view, cx| {
733                view.anchor = Anchor::TopRight;
734                view.arrow = false;
735                cx.notify();
736            });
737            window.draw(cx).clear(cx);
738        });
739        assert_eq!(
740            window.debug_bounds("positioned-content").unwrap().origin,
741            point(px(180.), px(60.))
742        );
743    }
744
745    #[gpui::test]
746    fn anchor_does_not_flip_when_offset_or_arrow_is_enabled(cx: &mut gpui::TestAppContext) {
747        cx.update(crate::init);
748        let (_, window) = cx.add_window_view(|_, _| AnchorHarness {
749            anchor: Anchor::BottomCenter,
750            offset: Some(px(12.)),
751            origin: point(px(200.), px(8.)),
752            arrow: true,
753        });
754        window.update(|window, cx| window.draw(cx).clear(cx));
755        window.update(|window, cx| window.draw(cx).clear(cx));
756        // Clamp to the window margin instead of flipping below the trigger.
757        assert_eq!(
758            window.debug_bounds("positioned-content").unwrap().top(),
759            px(8.)
760        );
761    }
762
763    #[test]
764    fn arrow_alignment_uses_the_anchor_instead_of_trigger_center() {
765        let trigger = Bounds::new(point(px(120.), px(120.)), size(px(40.), px(20.)));
766        for (anchor, side, target) in [
767            (
768                Anchor::TopLeft,
769                Placement::Bottom,
770                point(px(120.), px(140.)),
771            ),
772            (
773                Anchor::TopCenter,
774                Placement::Bottom,
775                point(px(140.), px(140.)),
776            ),
777            (
778                Anchor::TopRight,
779                Placement::Bottom,
780                point(px(160.), px(140.)),
781            ),
782            (
783                Anchor::BottomLeft,
784                Placement::Top,
785                point(px(120.), px(120.)),
786            ),
787            (
788                Anchor::BottomCenter,
789                Placement::Top,
790                point(px(140.), px(120.)),
791            ),
792            (
793                Anchor::BottomRight,
794                Placement::Top,
795                point(px(160.), px(120.)),
796            ),
797            (
798                Anchor::LeftCenter,
799                Placement::Right,
800                point(px(160.), px(130.)),
801            ),
802            (
803                Anchor::RightCenter,
804                Placement::Left,
805                point(px(120.), px(130.)),
806            ),
807        ] {
808            assert_eq!(arrow_anchor(anchor, trigger), (side, target));
809        }
810    }
811
812    #[test]
813    fn arrows_point_toward_the_trigger_on_every_resolved_side() {
814        let surface = Bounds::new(point(px(100.), px(100.)), size(px(80.), px(60.)));
815        for (side, trigger, tip) in [
816            (
817                Placement::Bottom,
818                Bounds::new(point(px(120.), px(50.)), size(px(40.), px(20.))),
819                point(px(140.), px(94.)),
820            ),
821            (
822                Placement::Top,
823                Bounds::new(point(px(120.), px(180.)), size(px(40.), px(20.))),
824                point(px(140.), px(166.)),
825            ),
826            (
827                Placement::Right,
828                Bounds::new(point(px(40.), px(120.)), size(px(40.), px(20.))),
829                point(px(94.), px(130.)),
830            ),
831            (
832                Placement::Left,
833                Bounds::new(point(px(200.), px(120.)), size(px(40.), px(20.))),
834                point(px(186.), px(130.)),
835            ),
836        ] {
837            assert_eq!(arrow_points(surface, trigger, side, px(6.), px(4.))[1], tip);
838        }
839        let clamped = arrow_points(
840            surface,
841            Bounds::new(point(px(0.), px(50.)), size(px(20.), px(20.))),
842            Placement::Bottom,
843            px(6.),
844            px(4.),
845        );
846        assert_eq!(clamped[0], point(px(104.), px(100.)));
847        assert_eq!(clamped[1], point(px(110.), px(94.)));
848    }
849
850    #[test]
851    fn arrow_join_covers_both_sides_of_the_surface_edge() {
852        let surface = Bounds::new(point(px(100.), px(100.)), size(px(80.), px(60.)));
853        let trigger = Bounds::new(point(px(120.), px(120.)), size(px(40.), px(20.)));
854        for (side, expected) in [
855            (
856                Placement::Bottom,
857                Bounds::new(point(px(135.), px(99.)), size(px(10.), px(2.))),
858            ),
859            (
860                Placement::Top,
861                Bounds::new(point(px(135.), px(159.)), size(px(10.), px(2.))),
862            ),
863            (
864                Placement::Right,
865                Bounds::new(point(px(99.), px(125.)), size(px(2.), px(10.))),
866            ),
867            (
868                Placement::Left,
869                Bounds::new(point(px(179.), px(125.)), size(px(2.), px(10.))),
870            ),
871        ] {
872            let points = arrow_points(surface, trigger, side, px(6.), px(4.));
873            assert_eq!(arrow_join_bounds(points, side, px(1.)), expected);
874        }
875    }
876
877    impl Render for PopoverHarness {
878        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
879            let changes = self.changes.clone();
880            Popover::new("runtime-popover")
881                .trigger(Button::new("runtime-trigger").label("Open").size(px(100.)))
882                .content(|_, _, _| {
883                    div()
884                        .debug_selector(|| "runtime-popover-content".into())
885                        .size(px(40.))
886                })
887                .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
888        }
889    }
890
891    #[gpui::test]
892    fn pointer_open_and_outside_dismiss_use_the_base_popup_host(cx: &mut gpui::TestAppContext) {
893        cx.update(|cx| {
894            gpui_base::GlobalState::init(cx);
895            cx.set_global(Theme::default());
896            init(cx);
897        });
898
899        let changes = Rc::new(RefCell::new(Vec::new()));
900        let (_, cx) = cx.add_window_view({
901            let changes = changes.clone();
902            move |_, _| PopoverHarness { changes }
903        });
904        cx.update(|window, cx| window.draw(cx).clear(cx));
905
906        cx.simulate_click(point(px(20.), px(20.)), Default::default());
907        cx.update(|window, cx| window.draw(cx).clear(cx));
908        assert!(cx.debug_bounds("runtime-popover-content").is_some());
909
910        cx.simulate_click(point(px(300.), px(300.)), Default::default());
911        cx.update(|window, cx| window.draw(cx).clear(cx));
912        assert!(cx.debug_bounds("runtime-popover-content").is_none());
913        // A change callback reports state transitions, not redundant dismissal
914        // requests. The base host may see both paths, but only the first closes.
915        assert_eq!(&*changes.borrow(), &[true, false]);
916    }
917
918    struct DefaultOpenHarness;
919
920    impl Render for DefaultOpenHarness {
921        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
922            Popover::new("default-open-popover")
923                .default_open(true)
924                .trigger(Button::new("default-open-trigger").label("Open"))
925                .child(
926                    div()
927                        .debug_selector(|| "default-open-content".into())
928                        .size(px(40.)),
929                )
930        }
931    }
932
933    #[gpui::test]
934    fn default_open_is_forwarded_to_the_base_popover(cx: &mut gpui::TestAppContext) {
935        cx.update(|cx| {
936            gpui_base::GlobalState::init(cx);
937            cx.set_global(Theme::default());
938            init(cx);
939        });
940        let (_, cx) = cx.add_window_view(|_, _| DefaultOpenHarness);
941        cx.update(|window, cx| window.draw(cx).clear(cx));
942        cx.update(|window, cx| window.draw(cx).clear(cx));
943        assert!(cx.debug_bounds("default-open-content").is_some());
944    }
945
946    struct Harness {
947        open: bool,
948    }
949
950    impl Render for Harness {
951        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
952            div().size_full().when(self.open, |this| {
953                this.child(dropdown_popup(
954                    "dropdown",
955                    Bounds::new(point(px(0.), px(100.)), size(px(120.), px(30.))),
956                    div().debug_selector(|| "surface".into()).size(px(50.)),
957                    cx,
958                ))
959            })
960        }
961    }
962
963    /// A dropdown that reused one animation key across opens would play its
964    /// enter motion the first time and then appear already settled on every
965    /// open after that. That is invisible in any single frame and easy to
966    /// reintroduce by giving the animation a constant id, so it is pinned here.
967    #[gpui::test]
968    fn the_enter_motion_starts_over_every_time_the_dropdown_opens(cx: &mut gpui::TestAppContext) {
969        cx.update(crate::init);
970        let (view, window) = cx.add_window_view(|_, _| Harness { open: true });
971
972        window.update(|window, cx| window.draw(cx).clear(cx));
973        let opening = window.debug_bounds("surface").unwrap().origin;
974
975        // The animation runs off the wall clock, so settling is waited out
976        // rather than stepped. Several times the duration leaves room for a
977        // loaded machine.
978        std::thread::sleep(DROPDOWN_ENTER_DURATION * 4);
979        window.update(|window, cx| window.draw(cx).clear(cx));
980        let settled = window.debug_bounds("surface").unwrap().origin;
981
982        assert!(
983            opening.y < settled.y,
984            "the surface should slide down into place, from {opening:?} to {settled:?}",
985        );
986
987        for open in [false, true] {
988            window.update(|window, cx| {
989                view.update(cx, |this, cx| {
990                    this.open = open;
991                    cx.notify();
992                });
993                window.draw(cx).clear(cx);
994            });
995        }
996
997        let reopening = window.debug_bounds("surface").unwrap().origin;
998        assert!(
999            reopening.y < settled.y,
1000            "reopening should start the motion over at {opening:?} rather than showing a \
1001             settled surface, but the first frame was already at {reopening:?}",
1002        );
1003    }
1004}