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
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 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 pub fn offset(mut self, offset: Pixels) -> Self {
62 self.offset = offset;
63 self
64 }
65
66 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
102fn 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 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 .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 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 assert!(content_hovered.get());
302
303 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 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 #[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}