1use gpui::{
2 Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Div, ElementId,
3 FocusHandle, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels,
4 RenderOnce, Stateful, StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px,
5};
6use std::{rc::Rc, time::Duration};
7
8use crate::ThemeStyled as _;
9use crate::{
10 Selectable, StyledExt as _,
11 animation::ease_out_cubic,
12 styled::{popover_ring, popover_shadow},
13 v_flex,
14};
15use gpui_base::Popover as BasePopover;
16pub use gpui_base::PopoverState;
17
18pub(crate) fn init(_: &mut App) {}
19
20const DROPDOWN_ENTER_DURATION: Duration = Duration::from_millis(150);
25
26const DROPDOWN_ENTER_OFFSET: Pixels = px(-8.);
32
33fn dropdown_positioner(bounds: Bounds<Pixels>) -> gpui_base::Positioner {
34 gpui_base::Positioner::side(bounds)
35 .placement(gpui_base::Placement::Bottom)
36 .align(gpui_base::Align::Start)
37 .offset(px(6.))
38 .margin(px(8.))
39}
40
41pub(crate) fn dropdown_popup(
83 id: impl Into<ElementId>,
84 bounds: Bounds<Pixels>,
85 surface: impl IntoElement + Styled + 'static,
86 cx: &App,
87) -> gpui_base::Positioner {
88 let travel: f32 = DROPDOWN_ENTER_OFFSET.into();
89 let ring = popover_ring(cx);
91
92 dropdown_positioner(bounds).child(surface.with_animation(
93 id,
94 Animation::new(DROPDOWN_ENTER_DURATION).with_easing(ease_out_cubic),
95 move |surface, delta| {
96 surface
97 .top(px(travel * (1. - delta)))
98 .opacity(delta)
99 .shadow(popover_shadow(ring, delta * delta * delta))
100 },
101 ))
102}
103
104#[derive(IntoElement)]
106pub struct Popover {
107 id: ElementId,
108 style: StyleRefinement,
109 anchor: Anchor,
110 default_open: bool,
111 open: Option<bool>,
112 tracked_focus_handle: Option<FocusHandle>,
113 trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
114 content: Option<
115 Rc<
116 dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
117 + 'static,
118 >,
119 >,
120 children: Vec<AnyElement>,
121 trigger_style: Option<StyleRefinement>,
124 mouse_button: MouseButton,
125 appearance: bool,
126 overlay_closable: bool,
127 on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
128}
129
130impl Popover {
131 pub fn new(id: impl Into<ElementId>) -> Self {
133 Self {
134 id: id.into(),
135 style: StyleRefinement::default(),
136 anchor: Anchor::TopLeft,
137 trigger: None,
138 trigger_style: None,
139 content: None,
140 tracked_focus_handle: None,
141 children: vec![],
142 mouse_button: MouseButton::Left,
143 appearance: true,
144 overlay_closable: true,
145 default_open: false,
146 open: None,
147 on_open_change: None,
148 }
149 }
150
151 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
158 self.anchor = anchor.into();
159 self
160 }
161
162 pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
164 self.mouse_button = mouse_button;
165 self
166 }
167
168 pub fn trigger<T>(mut self, trigger: T) -> Self
170 where
171 T: Selectable + IntoElement + 'static,
172 {
173 self.trigger = Some(Box::new(|is_open, _, _| {
174 let selected = trigger.is_selected();
175 trigger.selected(selected || is_open).into_any_element()
176 }));
177 self
178 }
179
180 pub fn default_open(mut self, open: bool) -> Self {
186 self.default_open = open;
187 self
188 }
189
190 pub fn open(mut self, open: bool) -> Self {
196 self.open = Some(open);
197 self
198 }
199
200 pub fn on_open_change<F>(mut self, callback: F) -> Self
206 where
207 F: Fn(&bool, &mut Window, &mut App) + 'static,
208 {
209 self.on_open_change = Some(Rc::new(callback));
210 self
211 }
212
213 pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
215 self.trigger_style = Some(style);
216 self
217 }
218
219 pub fn overlay_closable(mut self, closable: bool) -> Self {
221 self.overlay_closable = closable;
222 self
223 }
224
225 pub fn content<F, E>(mut self, content: F) -> Self
230 where
231 E: IntoElement,
232 F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
233 {
234 self.content = Some(Rc::new(move |state, window, cx| {
235 content(state, window, cx).into_any_element()
236 }));
237 self
238 }
239
240 pub fn appearance(mut self, appearance: bool) -> Self {
247 self.appearance = appearance;
248 self
249 }
250
251 pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
256 self.tracked_focus_handle = Some(handle.clone());
257 self
258 }
259}
260
261impl ParentElement for Popover {
262 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
263 self.children.extend(elements);
264 }
265}
266
267impl Styled for Popover {
268 fn style(&mut self) -> &mut StyleRefinement {
269 &mut self.style
270 }
271}
272
273impl Popover {
274 pub(crate) fn render_popover_content(
275 anchor: Anchor,
276 appearance: bool,
277 _: &mut Window,
278 cx: &mut App,
279 ) -> Stateful<Div> {
280 v_flex()
281 .id("content")
282 .occlude()
283 .tab_group()
284 .when(appearance, |this| this.popover_style(cx).p_3())
285 .map(|this| match anchor {
286 Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
287 Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
288 Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), })
290 }
291}
292
293impl RenderOnce for Popover {
294 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
295 let anchor = self.anchor;
296 let appearance = self.appearance;
297 let style = self.style;
298 let children = self.children;
299 let content = self.content;
300
301 BasePopover::new(self.id)
302 .anchor(self.anchor)
303 .mouse_button(self.mouse_button)
304 .default_open(self.default_open)
305 .overlay_closable(self.overlay_closable)
306 .content(move |state, window, cx| {
307 Self::render_popover_content(anchor, appearance, window, cx)
308 .when_some(content, |this, content| {
309 this.child((content)(state, window, cx))
310 })
311 .children(children)
312 .refine_style(&style)
313 })
314 .when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
315 .when_some(self.open, |this, open| this.open(open))
316 .when_some(self.tracked_focus_handle, |this, handle| {
317 this.track_focus(&handle)
318 })
319 .when_some(self.on_open_change, |this, callback| {
320 this.on_open_change(move |open, window, cx| callback(open, window, cx))
321 })
322 .into_any_element()
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::{button::Button, theme::Theme};
330 use gpui::{Bounds, Context, MouseButton, Point, Render, div, point, px, size};
331 use gpui_base::Popup as BasePopup;
332 use std::{cell::RefCell, rc::Rc};
333
334 #[test]
335 fn test_popover_builder_chaining() {
336 let popover = Popover::new("test")
337 .anchor(Anchor::BottomCenter)
338 .mouse_button(MouseButton::Right)
339 .default_open(true)
340 .appearance(false)
341 .overlay_closable(false);
342
343 assert_eq!(popover.anchor, Anchor::BottomCenter);
344 assert_eq!(popover.mouse_button, MouseButton::Right);
345 assert!(popover.default_open);
346 assert!(!popover.appearance);
347 assert!(!popover.overlay_closable);
348 }
349
350 #[test]
351 fn test_resolved_corner_top_positions() {
352 use gpui::px;
353
354 let bounds = Bounds {
355 origin: Point {
356 x: px(100.),
357 y: px(100.),
358 },
359 size: gpui::Size {
360 width: px(200.),
361 height: px(50.),
362 },
363 };
364
365 let pos = BasePopup::resolved_corner(Anchor::TopLeft, bounds);
366 assert_eq!(pos.x, px(100.));
367 assert_eq!(pos.y, px(100.));
368
369 let pos = BasePopup::resolved_corner(Anchor::TopCenter, bounds);
370 assert_eq!(pos.x, px(200.));
371 assert_eq!(pos.y, px(100.));
372
373 let pos = BasePopup::resolved_corner(Anchor::TopRight, bounds);
374 assert_eq!(pos.x, px(300.));
375 assert_eq!(pos.y, px(100.));
376
377 let pos = BasePopup::resolved_corner(Anchor::BottomLeft, bounds);
378 assert_eq!(pos.x, px(100.));
379 assert_eq!(pos.y, px(50.));
380
381 let pos = BasePopup::resolved_corner(Anchor::BottomCenter, bounds);
382 assert_eq!(pos.x, px(200.));
383 assert_eq!(pos.y, px(50.));
384
385 let pos = BasePopup::resolved_corner(Anchor::BottomRight, bounds);
386 assert_eq!(pos.x, px(300.));
387 assert_eq!(pos.y, px(50.));
388 }
389
390 struct PopoverHarness {
391 changes: Rc<RefCell<Vec<bool>>>,
392 }
393
394 impl Render for PopoverHarness {
395 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
396 let changes = self.changes.clone();
397 Popover::new("runtime-popover")
398 .trigger(Button::new("runtime-trigger").label("Open").size(px(100.)))
399 .content(|_, _, _| {
400 div()
401 .debug_selector(|| "runtime-popover-content".into())
402 .size(px(40.))
403 })
404 .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
405 }
406 }
407
408 #[gpui::test]
409 fn pointer_open_and_outside_dismiss_use_the_base_popup_host(cx: &mut gpui::TestAppContext) {
410 cx.update(|cx| {
411 gpui_base::GlobalState::init(cx);
412 cx.set_global(Theme::default());
413 init(cx);
414 });
415
416 let changes = Rc::new(RefCell::new(Vec::new()));
417 let (_, cx) = cx.add_window_view({
418 let changes = changes.clone();
419 move |_, _| PopoverHarness { changes }
420 });
421 cx.update(|window, cx| window.draw(cx).clear(cx));
422
423 cx.simulate_click(point(px(20.), px(20.)), Default::default());
424 cx.update(|window, cx| window.draw(cx).clear(cx));
425 assert!(cx.debug_bounds("runtime-popover-content").is_some());
426
427 cx.simulate_click(point(px(300.), px(300.)), Default::default());
428 cx.update(|window, cx| window.draw(cx).clear(cx));
429 assert!(cx.debug_bounds("runtime-popover-content").is_none());
430 assert_eq!(&*changes.borrow(), &[true, false]);
433 }
434
435 struct DefaultOpenHarness;
436
437 impl Render for DefaultOpenHarness {
438 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
439 Popover::new("default-open-popover")
440 .default_open(true)
441 .trigger(Button::new("default-open-trigger").label("Open"))
442 .child(
443 div()
444 .debug_selector(|| "default-open-content".into())
445 .size(px(40.)),
446 )
447 }
448 }
449
450 #[gpui::test]
451 fn default_open_is_forwarded_to_the_base_popover(cx: &mut gpui::TestAppContext) {
452 cx.update(|cx| {
453 gpui_base::GlobalState::init(cx);
454 cx.set_global(Theme::default());
455 init(cx);
456 });
457 let (_, cx) = cx.add_window_view(|_, _| DefaultOpenHarness);
458 cx.update(|window, cx| window.draw(cx).clear(cx));
459 cx.update(|window, cx| window.draw(cx).clear(cx));
460 assert!(cx.debug_bounds("default-open-content").is_some());
461 }
462
463 struct Harness {
464 open: bool,
465 }
466
467 impl Render for Harness {
468 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
469 div().size_full().when(self.open, |this| {
470 this.child(dropdown_popup(
471 "dropdown",
472 Bounds::new(point(px(0.), px(100.)), size(px(120.), px(30.))),
473 div().debug_selector(|| "surface".into()).size(px(50.)),
474 cx,
475 ))
476 })
477 }
478 }
479
480 #[gpui::test]
485 fn the_enter_motion_starts_over_every_time_the_dropdown_opens(cx: &mut gpui::TestAppContext) {
486 cx.update(crate::init);
487 let (view, window) = cx.add_window_view(|_, _| Harness { open: true });
488
489 window.update(|window, cx| window.draw(cx).clear(cx));
490 let opening = window.debug_bounds("surface").unwrap().origin;
491
492 std::thread::sleep(DROPDOWN_ENTER_DURATION * 4);
496 window.update(|window, cx| window.draw(cx).clear(cx));
497 let settled = window.debug_bounds("surface").unwrap().origin;
498
499 assert!(
500 opening.y < settled.y,
501 "the surface should slide down into place, from {opening:?} to {settled:?}",
502 );
503
504 for open in [false, true] {
505 window.update(|window, cx| {
506 view.update(cx, |this, cx| {
507 this.open = open;
508 cx.notify();
509 });
510 window.draw(cx).clear(cx);
511 });
512 }
513
514 let reopening = window.debug_bounds("surface").unwrap().origin;
515 assert!(
516 reopening.y < settled.y,
517 "reopening should start the motion over at {opening:?} rather than showing a \
518 settled surface, but the first frame was already at {reopening:?}",
519 );
520 }
521}