Skip to main content

gpui_base/
popup.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4    Anchor, AnyElement, App, Bounds, Div, ElementId, InteractiveElement, Interactivity,
5    IntoElement, ParentElement, Pixels, Point, RenderOnce, StatefulInteractiveElement,
6    StyleRefinement, Styled, Window, deferred, div, px,
7};
8
9use crate::{ElementExt as _, Positioner, StyledExt as _};
10
11/// Distance kept between a popup and the window edge.
12const WINDOW_MARGIN: Pixels = px(8.);
13
14/// Deferred paint priority for interactive surfaces that must appear above dialogs.
15pub const POPUP_PRIORITY: usize = 100;
16
17#[derive(Default)]
18struct PopupAnchorState {
19    bounds: Bounds<Pixels>,
20    captured: bool,
21}
22
23/// An unstyled trigger and anchored popup host.
24///
25/// `Popup` owns trigger measurement, anchor-point calculation, first-frame
26/// synchronization, deferred rendering, and window-edge snapping. Callers own
27/// open state, interaction, popup content, appearance, and motion.
28#[derive(IntoElement)]
29pub struct Popup {
30    id: ElementId,
31    base: gpui::Stateful<Div>,
32    style: StyleRefinement,
33    anchor: Anchor,
34    trigger: AnyElement,
35    content: Option<AnyElement>,
36}
37
38impl Popup {
39    pub fn new(id: impl Into<ElementId>, trigger: impl IntoElement) -> Self {
40        let id = id.into();
41        Self {
42            base: div().id(id.clone()),
43            id,
44            style: StyleRefinement::default(),
45            anchor: Anchor::TopLeft,
46            trigger: trigger.into_any_element(),
47            content: None,
48        }
49    }
50
51    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
52        self.anchor = anchor.into();
53        self
54    }
55
56    pub fn content(mut self, content: impl IntoElement) -> Self {
57        self.content = Some(content.into_any_element());
58        self
59    }
60
61    pub fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
62        match anchor {
63            Anchor::TopLeft => trigger_bounds.origin,
64            Anchor::TopCenter => trigger_bounds.top_center(),
65            Anchor::TopRight => trigger_bounds.top_right(),
66            Anchor::BottomLeft => Point {
67                x: trigger_bounds.origin.x,
68                y: trigger_bounds.origin.y - trigger_bounds.size.height,
69            },
70            Anchor::BottomCenter => Point {
71                x: trigger_bounds.top_center().x,
72                y: trigger_bounds.origin.y - trigger_bounds.size.height,
73            },
74            Anchor::BottomRight => Point {
75                x: trigger_bounds.top_right().x,
76                y: trigger_bounds.origin.y - trigger_bounds.size.height,
77            },
78            Anchor::LeftCenter | Anchor::RightCenter => trigger_bounds.origin,
79        }
80    }
81}
82
83impl Styled for Popup {
84    fn style(&mut self) -> &mut StyleRefinement {
85        &mut self.style
86    }
87}
88
89impl InteractiveElement for Popup {
90    fn interactivity(&mut self) -> &mut Interactivity {
91        self.base.interactivity()
92    }
93}
94
95impl StatefulInteractiveElement for Popup {}
96
97impl RenderOnce for Popup {
98    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
99        let state =
100            window.use_keyed_state((self.id, "anchor"), cx, |_, _| PopupAnchorState::default());
101        let anchor = self.anchor;
102        let position = Rc::new(Cell::new(Self::resolved_corner(
103            anchor,
104            state.read(cx).bounds,
105        )));
106
107        let root = self
108            .base
109            .child(self.trigger)
110            .on_prepaint({
111                let state = state.clone();
112                let position = position.clone();
113                move |bounds, window, cx| {
114                    position.set(Self::resolved_corner(anchor, bounds));
115                    let first = state.update(cx, |state, _| {
116                        let first = !state.captured;
117                        state.bounds = bounds;
118                        state.captured = true;
119                        first
120                    });
121                    if first {
122                        window.request_animation_frame();
123                    }
124                }
125            })
126            .refine_style(&self.style);
127
128        let Some(content) = self.content else {
129            return root;
130        };
131        if !state.read(cx).captured {
132            return root;
133        }
134
135        root.child(
136            deferred(
137                Positioner::corner(anchor, position.get())
138                    .margin(WINDOW_MARGIN)
139                    // The host blocks the mouse, so no caller has to remember:
140                    // what a popup covers belongs to the popup.
141                    .occlude()
142                    .child(content),
143            )
144            .with_priority(POPUP_PRIORITY),
145        )
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use gpui::{Context, Render, px};
153
154    #[test]
155    fn resolved_corner_preserves_existing_anchor_math() {
156        let bounds = Bounds {
157            origin: Point::new(px(100.), px(100.)),
158            size: gpui::Size::new(px(200.), px(50.)),
159        };
160        assert_eq!(
161            Popup::resolved_corner(Anchor::TopCenter, bounds),
162            Point::new(px(200.), px(100.))
163        );
164        assert_eq!(
165            Popup::resolved_corner(Anchor::BottomRight, bounds),
166            Point::new(px(300.), px(50.))
167        );
168    }
169
170    struct Harness;
171
172    impl Render for Harness {
173        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
174            Popup::new(
175                "popup",
176                div()
177                    .debug_selector(|| "popup-trigger".into())
178                    .size(px(100.)),
179            )
180            .content(
181                div()
182                    .debug_selector(|| "popup-content".into())
183                    .size(px(20.)),
184            )
185        }
186    }
187
188    /// A caller that styles its own surface — a hover card, a dropdown — does
189    /// not have to remember to block the mouse. The host does it, so the panel
190    /// a popup covers stops reacting to a pointer that is over the popup.
191    struct OcclusionHarness {
192        background_hovered: Rc<Cell<bool>>,
193        content_hovered: Rc<Cell<bool>>,
194    }
195
196    impl Render for OcclusionHarness {
197        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
198            let background = self.background_hovered.clone();
199            let content = self.content_hovered.clone();
200            div()
201                .relative()
202                .size(px(200.))
203                .child(
204                    div()
205                        .id("background")
206                        .absolute()
207                        .size_full()
208                        .on_mouse_move(move |_, _, _| background.set(true)),
209                )
210                .child(
211                    Popup::new("popup", div().size(px(100.))).content(
212                        div()
213                            .id("content")
214                            .size(px(40.))
215                            .on_mouse_move(move |_, _, _| content.set(true)),
216                    ),
217                )
218        }
219    }
220
221    #[gpui::test]
222    fn the_popup_surface_blocks_the_panel_it_covers(cx: &mut gpui::TestAppContext) {
223        let background_hovered = Rc::new(Cell::new(false));
224        let content_hovered = Rc::new(Cell::new(false));
225        let (_, window) = cx.add_window_view({
226            let background_hovered = background_hovered.clone();
227            let content_hovered = content_hovered.clone();
228            move |_, _| OcclusionHarness {
229                background_hovered,
230                content_hovered,
231            }
232        });
233        window.update(|window, cx| window.draw(cx).clear(cx));
234        window.update(|window, cx| window.draw(cx).clear(cx));
235
236        window.simulate_mouse_move(
237            gpui::point(px(20.), px(110.)),
238            None,
239            gpui::Modifiers::default(),
240        );
241        assert!(!background_hovered.get());
242        // The surface blocks what is behind it, not its own content: the
243        // hitbox goes in ahead of the children, never over them.
244        assert!(content_hovered.get());
245
246        // The same pointer outside the surface still reaches the panel, so the
247        // assertion above is about occlusion and not a dead listener.
248        window.simulate_mouse_move(
249            gpui::point(px(150.), px(180.)),
250            None,
251            gpui::Modifiers::default(),
252        );
253        assert!(background_hovered.get());
254    }
255
256    #[gpui::test]
257    fn trigger_capture_enables_deferred_content_on_the_next_frame(cx: &mut gpui::TestAppContext) {
258        let (_, window) = cx.add_window_view(|_, _| Harness);
259        window.update(|window, cx| window.draw(cx).clear(cx));
260        window.update(|window, cx| window.draw(cx).clear(cx));
261
262        assert_eq!(
263            window.debug_bounds("popup-trigger").unwrap().size,
264            gpui::Size::new(px(100.), px(100.))
265        );
266        assert_eq!(
267            window.debug_bounds("popup-content").unwrap().size,
268            gpui::Size::new(px(20.), px(20.))
269        );
270    }
271}