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 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 .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 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 assert!(content_hovered.get());
299
300 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}