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, canvas, deferred, div, point, px,
7};
8
9use crate::{Positioner, ResolvedPosition, 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    offset: Pixels,
35    on_position: Option<Box<dyn Fn(ResolvedPosition, Bounds<Pixels>)>>,
36    trigger: AnyElement,
37    content: Option<AnyElement>,
38}
39
40impl Popup {
41    pub fn new(id: impl Into<ElementId>, trigger: impl IntoElement) -> Self {
42        let id = id.into();
43        Self {
44            base: div().id(id.clone()),
45            id,
46            style: StyleRefinement::default(),
47            anchor: Anchor::TopLeft,
48            offset: px(0.),
49            on_position: None,
50            trigger: trigger.into_any_element(),
51            content: None,
52        }
53    }
54
55    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
56        self.anchor = anchor.into();
57        self
58    }
59
60    /// Gap from the trigger along the anchor's outward direction, zero by default.
61    pub fn offset(mut self, offset: Pixels) -> Self {
62        self.offset = offset;
63        self
64    }
65
66    /// Observe resolved popup and trigger bounds before content prepaint.
67    pub fn on_position(
68        mut self,
69        callback: impl Fn(ResolvedPosition, Bounds<Pixels>) + 'static,
70    ) -> Self {
71        self.on_position = Some(Box::new(callback));
72        self
73    }
74
75    pub fn content(mut self, content: impl IntoElement) -> Self {
76        self.content = Some(content.into_any_element());
77        self
78    }
79
80    pub fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
81        match anchor {
82            Anchor::TopLeft => trigger_bounds.origin,
83            Anchor::TopCenter => trigger_bounds.top_center(),
84            Anchor::TopRight => trigger_bounds.top_right(),
85            Anchor::BottomLeft => Point {
86                x: trigger_bounds.origin.x,
87                y: trigger_bounds.origin.y - trigger_bounds.size.height,
88            },
89            Anchor::BottomCenter => Point {
90                x: trigger_bounds.top_center().x,
91                y: trigger_bounds.origin.y - trigger_bounds.size.height,
92            },
93            Anchor::BottomRight => Point {
94                x: trigger_bounds.top_right().x,
95                y: trigger_bounds.origin.y - trigger_bounds.size.height,
96            },
97            Anchor::LeftCenter | Anchor::RightCenter => trigger_bounds.origin,
98        }
99    }
100}
101
102/// Match the popup's anchor to the opposite edge of the measured trigger.
103fn anchor_position(anchor: Anchor, trigger: Bounds<Pixels>, offset: Pixels) -> Point<Pixels> {
104    match anchor {
105        Anchor::TopLeft => trigger.bottom_left() + point(px(0.), offset),
106        Anchor::TopCenter => trigger.bottom_center() + point(px(0.), offset),
107        Anchor::TopRight => trigger.bottom_right() + point(px(0.), offset),
108        Anchor::BottomLeft => trigger.origin - point(px(0.), offset),
109        Anchor::BottomCenter => trigger.top_center() - point(px(0.), offset),
110        Anchor::BottomRight => trigger.top_right() - point(px(0.), offset),
111        Anchor::LeftCenter => trigger.right_center() + point(offset, px(0.)),
112        Anchor::RightCenter => trigger.left_center() - point(offset, px(0.)),
113    }
114}
115
116impl Styled for Popup {
117    fn style(&mut self) -> &mut StyleRefinement {
118        &mut self.style
119    }
120}
121
122impl InteractiveElement for Popup {
123    fn interactivity(&mut self) -> &mut Interactivity {
124        self.base.interactivity()
125    }
126}
127
128impl StatefulInteractiveElement for Popup {}
129
130impl RenderOnce for Popup {
131    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
132        let state =
133            window.use_keyed_state((self.id, "anchor"), cx, |_, _| PopupAnchorState::default());
134        let anchor = self.anchor;
135        let trigger_bounds = Rc::new(Cell::new(state.read(cx).bounds));
136        let offset = self.offset;
137        let position = Rc::new(Cell::new(anchor_position(
138            anchor,
139            state.read(cx).bounds,
140            offset,
141        )));
142
143        let root = self
144            .base
145            .child(self.trigger)
146            .child(
147                canvas(
148                    {
149                        let state = state.clone();
150                        let position = position.clone();
151                        let trigger_bounds = trigger_bounds.clone();
152                        move |bounds, window, cx| {
153                            trigger_bounds.set(bounds);
154                            position.set(anchor_position(anchor, bounds, offset));
155                            let first = state.update(cx, |state, _| {
156                                let first = !state.captured;
157                                state.bounds = bounds;
158                                state.captured = true;
159                                first
160                            });
161                            if first {
162                                window.request_animation_frame();
163                            }
164                        }
165                    },
166                    |_, _, _, _| {},
167                )
168                .absolute()
169                .size_full()
170                .top_0()
171                .left_0(),
172            )
173            .refine_style(&self.style);
174
175        let Some(content) = self.content else {
176            return root;
177        };
178        if !state.read(cx).captured {
179            return root;
180        }
181
182        let positioner =
183            Positioner::corner(anchor, position.get()).tracked_corner_position(position);
184        let positioner = if let Some(callback) = self.on_position {
185            positioner.on_position(move |position| callback(position, trigger_bounds.get()))
186        } else {
187            positioner
188        };
189        root.child(
190            deferred(
191                positioner
192                    .margin(WINDOW_MARGIN)
193                    // The host blocks the mouse, so no caller has to remember:
194                    // what a popup covers belongs to the popup.
195                    .occlude()
196                    .child(content),
197            )
198            .with_priority(POPUP_PRIORITY),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use gpui::{Context, Render, px};
207
208    #[test]
209    fn resolved_corner_preserves_existing_anchor_math() {
210        let bounds = Bounds {
211            origin: Point::new(px(100.), px(100.)),
212            size: gpui::Size::new(px(200.), px(50.)),
213        };
214        assert_eq!(
215            Popup::resolved_corner(Anchor::TopCenter, bounds),
216            Point::new(px(200.), px(100.))
217        );
218        assert_eq!(
219            Popup::resolved_corner(Anchor::BottomRight, bounds),
220            Point::new(px(300.), px(50.))
221        );
222    }
223
224    struct Harness;
225
226    impl Render for Harness {
227        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
228            Popup::new(
229                "popup",
230                div()
231                    .debug_selector(|| "popup-trigger".into())
232                    .size(px(100.)),
233            )
234            .content(
235                div()
236                    .debug_selector(|| "popup-content".into())
237                    .size(px(20.)),
238            )
239        }
240    }
241
242    /// A caller that styles its own surface — a hover card, a dropdown — does
243    /// not have to remember to block the mouse. The host does it, so the panel
244    /// a popup covers stops reacting to a pointer that is over the popup.
245    struct OcclusionHarness {
246        background_hovered: Rc<Cell<bool>>,
247        content_hovered: Rc<Cell<bool>>,
248    }
249
250    impl Render for OcclusionHarness {
251        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
252            let background = self.background_hovered.clone();
253            let content = self.content_hovered.clone();
254            div()
255                .relative()
256                .size(px(200.))
257                .child(
258                    div()
259                        .id("background")
260                        .absolute()
261                        .size_full()
262                        .on_mouse_move(move |_, _, _| background.set(true)),
263                )
264                .child(
265                    Popup::new("popup", div().size(px(100.))).content(
266                        div()
267                            .id("content")
268                            .size(px(40.))
269                            .on_mouse_move(move |_, _, _| content.set(true)),
270                    ),
271                )
272        }
273    }
274
275    #[gpui::test]
276    fn the_popup_surface_blocks_the_panel_it_covers(cx: &mut gpui::TestAppContext) {
277        let background_hovered = Rc::new(Cell::new(false));
278        let content_hovered = Rc::new(Cell::new(false));
279        let (_, window) = cx.add_window_view({
280            let background_hovered = background_hovered.clone();
281            let content_hovered = content_hovered.clone();
282            move |_, _| OcclusionHarness {
283                background_hovered,
284                content_hovered,
285            }
286        });
287        window.update(|window, cx| window.draw(cx).clear(cx));
288        window.update(|window, cx| window.draw(cx).clear(cx));
289
290        window.simulate_mouse_move(
291            gpui::point(px(20.), px(110.)),
292            None,
293            gpui::Modifiers::default(),
294        );
295        assert!(!background_hovered.get());
296        // The surface blocks what is behind it, not its own content: the
297        // hitbox goes in ahead of the children, never over them.
298        assert!(content_hovered.get());
299
300        // The same pointer outside the surface still reaches the panel, so the
301        // assertion above is about occlusion and not a dead listener.
302        window.simulate_mouse_move(
303            gpui::point(px(150.), px(180.)),
304            None,
305            gpui::Modifiers::default(),
306        );
307        assert!(background_hovered.get());
308    }
309
310    #[gpui::test]
311    fn trigger_capture_enables_deferred_content_on_the_next_frame(cx: &mut gpui::TestAppContext) {
312        let (_, window) = cx.add_window_view(|_, _| Harness);
313        window.update(|window, cx| window.draw(cx).clear(cx));
314        window.update(|window, cx| window.draw(cx).clear(cx));
315
316        assert_eq!(
317            window.debug_bounds("popup-trigger").unwrap().size,
318            gpui::Size::new(px(100.), px(100.))
319        );
320        assert_eq!(
321            window.debug_bounds("popup-content").unwrap().size,
322            gpui::Size::new(px(20.), px(20.))
323        );
324    }
325}