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
11const WINDOW_MARGIN: Pixels = px(8.);
13
14pub const POPUP_PRIORITY: usize = 100;
16
17#[derive(Default)]
18struct PopupAnchorState {
19 bounds: Bounds<Pixels>,
20 captured: bool,
21}
22
23#[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 .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 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 assert!(content_hovered.get());
245
246 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}