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        // A closed popup places nothing: it notes its trigger's bounds, for when
144        // it opens, without asking for another frame.
145        let has_content = self.content.is_some();
146        let root = self
147            .base
148            .child(self.trigger)
149            .child(
150                canvas(
151                    {
152                        let state = state.clone();
153                        let position = position.clone();
154                        let trigger_bounds = trigger_bounds.clone();
155                        move |bounds, window, cx| {
156                            trigger_bounds.set(bounds);
157                            position.set(anchor_position(anchor, bounds, offset));
158                            let first = state.update(cx, |state, _| {
159                                let first = !state.captured;
160                                state.bounds = bounds;
161                                state.captured = true;
162                                first
163                            });
164                            if first && has_content {
165                                window.request_animation_frame();
166                            }
167                        }
168                    },
169                    |_, _, _, _| {},
170                )
171                .absolute()
172                .size_full()
173                .top_0()
174                .left_0(),
175            )
176            .refine_style(&self.style);
177
178        let Some(content) = self.content else {
179            return root;
180        };
181        if !state.read(cx).captured {
182            return root;
183        }
184
185        let positioner =
186            Positioner::corner(anchor, position.get()).tracked_corner_position(position);
187        let positioner = if let Some(callback) = self.on_position {
188            positioner.on_position(move |position| callback(position, trigger_bounds.get()))
189        } else {
190            positioner
191        };
192        root.child(
193            deferred(
194                positioner
195                    .margin(WINDOW_MARGIN)
196                    // The host blocks the mouse, so no caller has to remember:
197                    // what a popup covers belongs to the popup.
198                    .occlude()
199                    .child(content),
200            )
201            .with_priority(POPUP_PRIORITY),
202        )
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use gpui::{Context, Render, px};
210
211    #[test]
212    fn resolved_corner_preserves_existing_anchor_math() {
213        let bounds = Bounds {
214            origin: Point::new(px(100.), px(100.)),
215            size: gpui::Size::new(px(200.), px(50.)),
216        };
217        assert_eq!(
218            Popup::resolved_corner(Anchor::TopCenter, bounds),
219            Point::new(px(200.), px(100.))
220        );
221        assert_eq!(
222            Popup::resolved_corner(Anchor::BottomRight, bounds),
223            Point::new(px(300.), px(50.))
224        );
225    }
226
227    struct Harness;
228
229    impl Render for Harness {
230        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
231            Popup::new(
232                "popup",
233                div()
234                    .debug_selector(|| "popup-trigger".into())
235                    .size(px(100.)),
236            )
237            .content(
238                div()
239                    .debug_selector(|| "popup-content".into())
240                    .size(px(20.)),
241            )
242        }
243    }
244
245    /// A caller that styles its own surface — a hover card, a dropdown — does
246    /// not have to remember to block the mouse. The host does it, so the panel
247    /// a popup covers stops reacting to a pointer that is over the popup.
248    struct OcclusionHarness {
249        background_hovered: Rc<Cell<bool>>,
250        content_hovered: Rc<Cell<bool>>,
251    }
252
253    impl Render for OcclusionHarness {
254        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
255            let background = self.background_hovered.clone();
256            let content = self.content_hovered.clone();
257            div()
258                .relative()
259                .size(px(200.))
260                .child(
261                    div()
262                        .id("background")
263                        .absolute()
264                        .size_full()
265                        .on_mouse_move(move |_, _, _| background.set(true)),
266                )
267                .child(
268                    Popup::new("popup", div().size(px(100.))).content(
269                        div()
270                            .id("content")
271                            .size(px(40.))
272                            .on_mouse_move(move |_, _, _| content.set(true)),
273                    ),
274                )
275        }
276    }
277
278    #[gpui::test]
279    fn the_popup_surface_blocks_the_panel_it_covers(cx: &mut gpui::TestAppContext) {
280        let background_hovered = Rc::new(Cell::new(false));
281        let content_hovered = Rc::new(Cell::new(false));
282        let (_, window) = cx.add_window_view({
283            let background_hovered = background_hovered.clone();
284            let content_hovered = content_hovered.clone();
285            move |_, _| OcclusionHarness {
286                background_hovered,
287                content_hovered,
288            }
289        });
290        window.update(|window, cx| window.draw(cx).clear(cx));
291        window.update(|window, cx| window.draw(cx).clear(cx));
292
293        window.simulate_mouse_move(
294            gpui::point(px(20.), px(110.)),
295            None,
296            gpui::Modifiers::default(),
297        );
298        assert!(!background_hovered.get());
299        // The surface blocks what is behind it, not its own content: the
300        // hitbox goes in ahead of the children, never over them.
301        assert!(content_hovered.get());
302
303        // The same pointer outside the surface still reaches the panel, so the
304        // assertion above is about occlusion and not a dead listener.
305        window.simulate_mouse_move(
306            gpui::point(px(150.), px(180.)),
307            None,
308            gpui::Modifiers::default(),
309        );
310        assert!(background_hovered.get());
311    }
312
313    /// A popup that may open later.
314    struct ClosedHarness {
315        open: bool,
316    }
317
318    impl Render for ClosedHarness {
319        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
320            let popup = Popup::new(
321                "popup",
322                div()
323                    .debug_selector(|| "popup-trigger".into())
324                    .size(px(100.)),
325            );
326            if self.open {
327                popup.content(
328                    div()
329                        .debug_selector(|| "popup-content".into())
330                        .size(px(20.)),
331                )
332            } else {
333                popup
334            }
335        }
336    }
337
338    /// A closed popup notes its trigger's bounds without asking for another
339    /// frame: a list of rows holding closed popups is not drawn again for
340    /// each row that shows. Opened, it places its content at once.
341    #[gpui::test]
342    fn a_closed_popup_asks_for_no_frame_and_opens_at_once(cx: &mut gpui::TestAppContext) {
343        let (view, window) = cx.add_window_view(|_, _| ClosedHarness { open: false });
344        window.update(|window, cx| {
345            window.draw(cx).clear(cx);
346            assert_eq!(window.simulate_next_frame(cx), 0, "no frame was asked for");
347        });
348
349        view.update(window, |harness, cx| {
350            harness.open = true;
351            cx.notify();
352        });
353        window.update(|window, cx| window.draw(cx).clear(cx));
354        assert_eq!(
355            window.debug_bounds("popup-content").unwrap().size,
356            gpui::Size::new(px(20.), px(20.))
357        );
358    }
359
360    #[gpui::test]
361    fn trigger_capture_enables_deferred_content_on_the_next_frame(cx: &mut gpui::TestAppContext) {
362        let (_, window) = cx.add_window_view(|_, _| Harness);
363        window.update(|window, cx| window.draw(cx).clear(cx));
364        window.update(|window, cx| window.draw(cx).clear(cx));
365
366        assert_eq!(
367            window.debug_bounds("popup-trigger").unwrap().size,
368            gpui::Size::new(px(100.), px(100.))
369        );
370        assert_eq!(
371            window.debug_bounds("popup-content").unwrap().size,
372            gpui::Size::new(px(20.), px(20.))
373        );
374    }
375}