1use gpui::{
2 Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Div, ElementId,
3 FocusHandle, InteractiveElement as _, IntoElement, MouseButton, ParentElement, PathBuilder,
4 Pixels, Point, RenderOnce, Stateful, StyleRefinement, Styled, Window, canvas, point,
5 prelude::FluentBuilder as _, px,
6};
7use std::{cell::Cell, rc::Rc, time::Duration};
8
9use crate::{ActiveTheme as _, ThemeStyled as _};
10use crate::{
11 Selectable, StyledExt as _,
12 animation::ease_out_cubic,
13 styled::{popover_ring, popover_shadow},
14 v_flex,
15};
16use gpui_base::Placement;
17use gpui_base::Popover as BasePopover;
18pub use gpui_base::PopoverState;
19
20pub(crate) fn init(_: &mut App) {}
21
22const DROPDOWN_ENTER_DURATION: Duration = Duration::from_millis(150);
27
28const DROPDOWN_ENTER_OFFSET: Pixels = px(-8.);
34
35fn dropdown_positioner(bounds: Bounds<Pixels>) -> gpui_base::Positioner {
36 gpui_base::Positioner::side(bounds)
37 .placement(gpui_base::Placement::Bottom)
38 .align(gpui_base::Align::Start)
39 .offset(px(6.))
40 .margin(px(8.))
41}
42
43pub(crate) fn dropdown_popup(
85 id: impl Into<ElementId>,
86 bounds: Bounds<Pixels>,
87 surface: impl IntoElement + Styled + 'static,
88 cx: &App,
89) -> gpui_base::Positioner {
90 let travel: f32 = DROPDOWN_ENTER_OFFSET.into();
91 let ring = popover_ring(cx);
93
94 dropdown_positioner(bounds).child(surface.with_animation(
95 id,
96 Animation::new(DROPDOWN_ENTER_DURATION).with_easing(ease_out_cubic),
97 move |surface, delta| {
98 surface
99 .top(px(travel * (1. - delta)))
100 .opacity(delta)
101 .shadow(popover_shadow(ring, delta * delta * delta))
102 },
103 ))
104}
105
106#[derive(IntoElement)]
108pub struct Popover {
109 id: ElementId,
110 style: StyleRefinement,
111 anchor: Anchor,
112 offset: Option<Pixels>,
113 arrow: bool,
114 default_open: bool,
115 open: Option<bool>,
116 tracked_focus_handle: Option<FocusHandle>,
117 trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
118 content: Option<
119 Rc<
120 dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
121 + 'static,
122 >,
123 >,
124 children: Vec<AnyElement>,
125 trigger_style: Option<StyleRefinement>,
128 mouse_button: MouseButton,
129 appearance: bool,
130 overlay_closable: bool,
131 on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
132}
133
134impl Popover {
135 pub fn new(id: impl Into<ElementId>) -> Self {
137 Self {
138 id: id.into(),
139 style: StyleRefinement::default(),
140 anchor: Anchor::TopLeft,
141 offset: None,
142 arrow: false,
143 trigger: None,
144 trigger_style: None,
145 content: None,
146 tracked_focus_handle: None,
147 children: vec![],
148 mouse_button: MouseButton::Left,
149 appearance: true,
150 overlay_closable: true,
151 default_open: false,
152 open: None,
153 on_open_change: None,
154 }
155 }
156
157 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
163 self.anchor = anchor.into();
164 self
165 }
166
167 pub fn offset(mut self, offset: impl Into<Pixels>) -> Self {
170 self.offset = Some(offset.into());
171 self
172 }
173
174 pub fn arrow(mut self, arrow: bool) -> Self {
178 self.arrow = arrow;
179 self
180 }
181
182 pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
184 self.mouse_button = mouse_button;
185 self
186 }
187
188 pub fn trigger<T>(mut self, trigger: T) -> Self
190 where
191 T: Selectable + IntoElement + 'static,
192 {
193 self.trigger = Some(Box::new(|is_open, _, _| {
194 let open = trigger.is_open();
195 trigger.open(open || is_open).into_any_element()
196 }));
197 self
198 }
199
200 pub fn default_open(mut self, open: bool) -> Self {
206 self.default_open = open;
207 self
208 }
209
210 pub fn open(mut self, open: bool) -> Self {
216 self.open = Some(open);
217 self
218 }
219
220 pub fn on_open_change<F>(mut self, callback: F) -> Self
226 where
227 F: Fn(&bool, &mut Window, &mut App) + 'static,
228 {
229 self.on_open_change = Some(Rc::new(callback));
230 self
231 }
232
233 pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
239 self.trigger_style = Some(style);
240 self
241 }
242
243 pub fn overlay_closable(mut self, closable: bool) -> Self {
245 self.overlay_closable = closable;
246 self
247 }
248
249 pub fn content<F, E>(mut self, content: F) -> Self
254 where
255 E: IntoElement,
256 F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
257 {
258 self.content = Some(Rc::new(move |state, window, cx| {
259 content(state, window, cx).into_any_element()
260 }));
261 self
262 }
263
264 pub fn appearance(mut self, appearance: bool) -> Self {
271 self.appearance = appearance;
272 self
273 }
274
275 pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
280 self.tracked_focus_handle = Some(handle.clone());
281 self
282 }
283}
284
285impl ParentElement for Popover {
286 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
287 self.children.extend(elements);
288 }
289}
290
291impl Styled for Popover {
292 fn style(&mut self) -> &mut StyleRefinement {
293 &mut self.style
294 }
295}
296
297impl Popover {
298 pub(crate) fn render_popover_content(
299 anchor: Anchor,
300 appearance: bool,
301 _: &mut Window,
302 cx: &mut App,
303 ) -> Stateful<Div> {
304 v_flex()
305 .id("content")
306 .occlude()
307 .tab_group()
308 .when(appearance, |this| this.popover_style(cx).p_3())
309 .map(|this| match anchor {
310 Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
311 Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
312 Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), })
314 }
315}
316
317impl RenderOnce for Popover {
318 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
319 let anchor = self.anchor;
320 let arrow_size = if self.arrow {
321 window.rem_size() * 0.375
322 } else {
323 px(0.)
324 };
325 let offset = self.offset.unwrap_or(window.rem_size() * 0.25) + arrow_size;
326 let resolved = Rc::new(Cell::new(None));
327 let arrow_position = resolved.clone();
328 let arrow = self.arrow;
329 let background = self
330 .style
331 .background
332 .as_ref()
333 .and_then(gpui::Fill::color)
334 .unwrap_or_else(|| cx.theme().popover.into());
335 let ring = popover_ring(cx);
336 let radius = cx.theme().radius;
337 let appearance = self.appearance;
338 let style = self.style;
339 let children = self.children;
340 let content = self.content;
341 let trigger_style = self.trigger_style;
342
343 BasePopover::new(self.id)
344 .anchor(self.anchor)
345 .offset(offset)
346 .on_position(move |position, trigger| resolved.set(Some((position, trigger))))
347 .mouse_button(self.mouse_button)
348 .default_open(self.default_open)
349 .overlay_closable(self.overlay_closable)
350 .content(move |state, window, cx| {
351 v_flex()
352 .id("content")
353 .occlude()
354 .tab_group()
355 .when(appearance, |this| this.popover_style(cx).p_3())
356 .when_some(content, |this, content| {
357 this.child((content)(state, window, cx))
358 })
359 .children(children)
360 .refine_style(&style)
361 .when(arrow, |this| {
362 this.child(
363 canvas(
364 |_, _, _| {},
365 move |bounds, _, window, _| {
366 let Some((_, trigger)) = arrow_position.get() else {
367 return;
368 };
369 let (side, target) = arrow_anchor(anchor, trigger);
370 let trigger = Bounds::new(target, gpui::size(px(0.), px(0.)));
371 let points =
372 arrow_points(bounds, trigger, side, arrow_size, radius);
373 let mut fill = PathBuilder::fill();
374 fill.move_to(points[0]);
375 fill.line_to(points[1]);
376 fill.line_to(points[2]);
377 fill.close();
378 if let Ok(path) = fill.build() {
379 window.paint_path(path, background);
380 }
381 window.paint_quad(gpui::fill(
385 arrow_join_bounds(points, side, px(1.)),
386 background,
387 ));
388 if appearance {
389 let mut outline = PathBuilder::stroke(px(1.));
390 outline.move_to(points[0]);
391 outline.line_to(points[1]);
392 outline.line_to(points[2]);
393 if let Ok(path) = outline.build() {
394 window.paint_path(path, ring);
395 }
396 }
397 },
398 )
399 .absolute()
400 .inset_0()
401 .size_full(),
402 )
403 })
404 })
405 .when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
406 .when_some(trigger_style, |this, style| this.refine_style(&style))
407 .when_some(self.open, |this, open| this.open(open))
408 .when_some(self.tracked_focus_handle, |this, handle| {
409 this.track_focus(&handle)
410 })
411 .when_some(self.on_open_change, |this, callback| {
412 this.on_open_change(move |open, window, cx| callback(open, window, cx))
413 })
414 .into_any_element()
415 }
416}
417
418fn arrow_anchor(anchor: Anchor, trigger: Bounds<Pixels>) -> (Placement, Point<Pixels>) {
420 match anchor {
421 Anchor::TopLeft => (Placement::Bottom, trigger.bottom_left()),
422 Anchor::TopCenter => (Placement::Bottom, trigger.bottom_center()),
423 Anchor::TopRight => (Placement::Bottom, trigger.bottom_right()),
424 Anchor::BottomLeft => (Placement::Top, trigger.origin),
425 Anchor::BottomCenter => (Placement::Top, trigger.top_center()),
426 Anchor::BottomRight => (Placement::Top, trigger.top_right()),
427 Anchor::LeftCenter => (Placement::Right, trigger.right_center()),
428 Anchor::RightCenter => (Placement::Left, trigger.left_center()),
429 }
430}
431
432fn arrow_points(
434 surface: Bounds<Pixels>,
435 trigger: Bounds<Pixels>,
436 side: Placement,
437 depth: Pixels,
438 radius: Pixels,
439) -> [Point<Pixels>; 3] {
440 let horizontal = side.is_horizontal();
441 let (start, end, target) = if horizontal {
442 (surface.top(), surface.bottom(), trigger.center().y)
443 } else {
444 (surface.left(), surface.right(), trigger.center().x)
445 };
446 let half = depth.min((end - start) * 0.5);
447 let inset = (radius + half).min((end - start) * 0.5);
448 let center = target.clamp(start + inset, end - inset);
449 match side {
450 Placement::Bottom => [
451 point(center - half, surface.top()),
452 point(center, surface.top() - depth),
453 point(center + half, surface.top()),
454 ],
455 Placement::Top => [
456 point(center - half, surface.bottom()),
457 point(center, surface.bottom() + depth),
458 point(center + half, surface.bottom()),
459 ],
460 Placement::Right => [
461 point(surface.left(), center - half),
462 point(surface.left() - depth, center),
463 point(surface.left(), center + half),
464 ],
465 Placement::Left => [
466 point(surface.right(), center - half),
467 point(surface.right() + depth, center),
468 point(surface.right(), center + half),
469 ],
470 }
471}
472
473fn arrow_join_bounds(
474 points: [Point<Pixels>; 3],
475 side: Placement,
476 stroke: Pixels,
477) -> Bounds<Pixels> {
478 if side.is_horizontal() {
481 let inset = stroke.min((points[2].y - points[0].y) * 0.5);
482 Bounds::from_corners(
483 point(points[0].x - stroke, points[0].y + inset),
484 point(points[2].x + stroke, points[2].y - inset),
485 )
486 } else {
487 let inset = stroke.min((points[2].x - points[0].x) * 0.5);
488 Bounds::from_corners(
489 point(points[0].x + inset, points[0].y - stroke),
490 point(points[2].x - inset, points[2].y + stroke),
491 )
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::{button::Button, h_flex, theme::Theme};
499 use gpui::{Bounds, Context, MouseButton, Point, Render, div, point, px, size};
500 use gpui_base::Popup as BasePopup;
501 use std::{cell::RefCell, rc::Rc};
502
503 #[test]
504 fn test_popover_builder_chaining() {
505 let popover = Popover::new("test")
506 .anchor(Anchor::BottomCenter)
507 .mouse_button(MouseButton::Right)
508 .default_open(true)
509 .appearance(false)
510 .overlay_closable(false);
511
512 assert_eq!(popover.anchor, Anchor::BottomCenter);
513 assert_eq!(popover.mouse_button, MouseButton::Right);
514 assert!(popover.default_open);
515 assert!(!popover.appearance);
516 assert!(!popover.overlay_closable);
517 }
518
519 #[test]
520 fn test_resolved_corner_top_positions() {
521 use gpui::px;
522
523 let bounds = Bounds {
524 origin: Point {
525 x: px(100.),
526 y: px(100.),
527 },
528 size: gpui::Size {
529 width: px(200.),
530 height: px(50.),
531 },
532 };
533
534 let pos = BasePopup::resolved_corner(Anchor::TopLeft, bounds);
535 assert_eq!(pos.x, px(100.));
536 assert_eq!(pos.y, px(100.));
537
538 let pos = BasePopup::resolved_corner(Anchor::TopCenter, bounds);
539 assert_eq!(pos.x, px(200.));
540 assert_eq!(pos.y, px(100.));
541
542 let pos = BasePopup::resolved_corner(Anchor::TopRight, bounds);
543 assert_eq!(pos.x, px(300.));
544 assert_eq!(pos.y, px(100.));
545
546 let pos = BasePopup::resolved_corner(Anchor::BottomLeft, bounds);
547 assert_eq!(pos.x, px(100.));
548 assert_eq!(pos.y, px(50.));
549
550 let pos = BasePopup::resolved_corner(Anchor::BottomCenter, bounds);
551 assert_eq!(pos.x, px(200.));
552 assert_eq!(pos.y, px(50.));
553
554 let pos = BasePopup::resolved_corner(Anchor::BottomRight, bounds);
555 assert_eq!(pos.x, px(300.));
556 assert_eq!(pos.y, px(50.));
557 }
558
559 struct PopoverHarness {
560 changes: Rc<RefCell<Vec<bool>>>,
561 }
562
563 struct AnchorHarness {
564 anchor: Anchor,
565 offset: Option<Pixels>,
566 origin: Point<Pixels>,
567 arrow: bool,
568 }
569
570 impl Render for AnchorHarness {
571 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
572 div().size_full().child(
573 div()
574 .absolute()
575 .left(self.origin.x)
576 .top(self.origin.y)
577 .child(
578 Popover::new("positioned-popover")
579 .default_open(true)
580 .appearance(false)
581 .arrow(self.arrow)
582 .anchor(self.anchor)
583 .when_some(self.offset, |this, gap| this.offset(gap))
584 .trigger(Button::new("positioned-trigger").size(px(40.)))
585 .child(
586 div()
587 .debug_selector(|| "positioned-content".into())
588 .size(px(60.)),
589 ),
590 ),
591 )
592 }
593 }
594
595 #[gpui::test]
596 fn anchor_and_offset_position_the_surface_on_each_trigger_edge(cx: &mut gpui::TestAppContext) {
597 cx.update(crate::init);
598 let (view, window) = cx.add_window_view(|_, _| AnchorHarness {
599 anchor: Anchor::TopLeft,
600 offset: None,
601 origin: point(px(200.), px(200.)),
602 arrow: false,
603 });
604 window.update(|window, cx| window.draw(cx).clear(cx));
605 window.update(|window, cx| window.draw(cx).clear(cx));
606 let legacy = window.debug_bounds("positioned-content").unwrap();
608 assert_eq!(legacy.left(), px(200.));
609 assert_eq!(legacy.top(), px(244.));
610
611 for (side, x, y) in [
612 (Anchor::BottomLeft, 200., 128.),
613 (Anchor::BottomCenter, 190., 128.),
614 (Anchor::BottomRight, 180., 128.),
615 (Anchor::TopLeft, 200., 252.),
616 (Anchor::TopCenter, 190., 252.),
617 (Anchor::TopRight, 180., 252.),
618 (Anchor::RightCenter, 128., 190.),
619 (Anchor::LeftCenter, 252., 190.),
620 ] {
621 window.update(|window, cx| {
622 view.update(cx, |view, cx| {
623 view.anchor = side;
624 view.offset = Some(px(12.));
625 cx.notify();
626 });
627 window.draw(cx).clear(cx);
628 });
629 assert_eq!(
630 window.debug_bounds("positioned-content").unwrap().origin,
631 point(px(x), px(y)),
632 "{side:?}"
633 );
634 }
635 window.update(|window, cx| {
637 view.update(cx, |view, cx| {
638 view.origin = point(px(260.), px(240.));
639 cx.notify();
640 });
641 window.draw(cx).clear(cx);
642 });
643 assert_eq!(
644 window.debug_bounds("positioned-content").unwrap().origin,
645 point(px(312.), px(230.))
646 );
647 }
648
649 struct TriggerStyleHarness {
650 styled: bool,
651 }
652
653 impl Render for TriggerStyleHarness {
654 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
655 div().size_full().child(
658 h_flex()
659 .absolute()
660 .left(px(100.))
661 .top(px(100.))
662 .w(px(200.))
663 .child(
664 Popover::new("trigger-style-popover")
665 .default_open(true)
666 .appearance(false)
667 .offset(px(0.))
668 .anchor(Anchor::TopRight)
669 .when(self.styled, |this| {
670 this.trigger_style(StyleRefinement::default().w_full())
671 })
672 .trigger(Button::new("styled-trigger").size(px(40.)))
673 .child(
674 div()
675 .debug_selector(|| "styled-content".into())
676 .size(px(20.)),
677 ),
678 ),
679 )
680 }
681 }
682
683 #[gpui::test]
687 fn trigger_style_is_applied_to_the_trigger_container(cx: &mut gpui::TestAppContext) {
688 cx.update(crate::init);
689 let (view, window) = cx.add_window_view(|_, _| TriggerStyleHarness { styled: false });
690 window.update(|window, cx| window.draw(cx).clear(cx));
691 window.update(|window, cx| window.draw(cx).clear(cx));
692 assert_eq!(
695 window.debug_bounds("styled-content").unwrap().origin,
696 point(px(120.), px(140.))
697 );
698
699 window.update(|window, cx| {
700 view.update(cx, |view, cx| {
701 view.styled = true;
702 cx.notify();
703 });
704 window.draw(cx).clear(cx);
705 });
706 window.update(|window, cx| window.draw(cx).clear(cx));
707 assert_eq!(
710 window.debug_bounds("styled-content").unwrap().origin,
711 point(px(280.), px(140.))
712 );
713 }
714
715 #[gpui::test]
716 fn arrow_reserves_space_without_changing_anchor_alignment(cx: &mut gpui::TestAppContext) {
717 cx.update(crate::init);
718 let (view, window) = cx.add_window_view(|_, _| AnchorHarness {
719 anchor: Anchor::TopLeft,
720 offset: Some(px(12.)),
721 origin: point(px(200.), px(8.)),
722 arrow: true,
723 });
724 window.update(|window, cx| window.draw(cx).clear(cx));
725 window.update(|window, cx| window.draw(cx).clear(cx));
726 assert_eq!(
728 window.debug_bounds("positioned-content").unwrap().origin,
729 point(px(200.), px(66.))
730 );
731 window.update(|window, cx| {
732 view.update(cx, |view, cx| {
733 view.anchor = Anchor::TopRight;
734 view.arrow = false;
735 cx.notify();
736 });
737 window.draw(cx).clear(cx);
738 });
739 assert_eq!(
740 window.debug_bounds("positioned-content").unwrap().origin,
741 point(px(180.), px(60.))
742 );
743 }
744
745 #[gpui::test]
746 fn anchor_does_not_flip_when_offset_or_arrow_is_enabled(cx: &mut gpui::TestAppContext) {
747 cx.update(crate::init);
748 let (_, window) = cx.add_window_view(|_, _| AnchorHarness {
749 anchor: Anchor::BottomCenter,
750 offset: Some(px(12.)),
751 origin: point(px(200.), px(8.)),
752 arrow: true,
753 });
754 window.update(|window, cx| window.draw(cx).clear(cx));
755 window.update(|window, cx| window.draw(cx).clear(cx));
756 assert_eq!(
758 window.debug_bounds("positioned-content").unwrap().top(),
759 px(8.)
760 );
761 }
762
763 #[test]
764 fn arrow_alignment_uses_the_anchor_instead_of_trigger_center() {
765 let trigger = Bounds::new(point(px(120.), px(120.)), size(px(40.), px(20.)));
766 for (anchor, side, target) in [
767 (
768 Anchor::TopLeft,
769 Placement::Bottom,
770 point(px(120.), px(140.)),
771 ),
772 (
773 Anchor::TopCenter,
774 Placement::Bottom,
775 point(px(140.), px(140.)),
776 ),
777 (
778 Anchor::TopRight,
779 Placement::Bottom,
780 point(px(160.), px(140.)),
781 ),
782 (
783 Anchor::BottomLeft,
784 Placement::Top,
785 point(px(120.), px(120.)),
786 ),
787 (
788 Anchor::BottomCenter,
789 Placement::Top,
790 point(px(140.), px(120.)),
791 ),
792 (
793 Anchor::BottomRight,
794 Placement::Top,
795 point(px(160.), px(120.)),
796 ),
797 (
798 Anchor::LeftCenter,
799 Placement::Right,
800 point(px(160.), px(130.)),
801 ),
802 (
803 Anchor::RightCenter,
804 Placement::Left,
805 point(px(120.), px(130.)),
806 ),
807 ] {
808 assert_eq!(arrow_anchor(anchor, trigger), (side, target));
809 }
810 }
811
812 #[test]
813 fn arrows_point_toward_the_trigger_on_every_resolved_side() {
814 let surface = Bounds::new(point(px(100.), px(100.)), size(px(80.), px(60.)));
815 for (side, trigger, tip) in [
816 (
817 Placement::Bottom,
818 Bounds::new(point(px(120.), px(50.)), size(px(40.), px(20.))),
819 point(px(140.), px(94.)),
820 ),
821 (
822 Placement::Top,
823 Bounds::new(point(px(120.), px(180.)), size(px(40.), px(20.))),
824 point(px(140.), px(166.)),
825 ),
826 (
827 Placement::Right,
828 Bounds::new(point(px(40.), px(120.)), size(px(40.), px(20.))),
829 point(px(94.), px(130.)),
830 ),
831 (
832 Placement::Left,
833 Bounds::new(point(px(200.), px(120.)), size(px(40.), px(20.))),
834 point(px(186.), px(130.)),
835 ),
836 ] {
837 assert_eq!(arrow_points(surface, trigger, side, px(6.), px(4.))[1], tip);
838 }
839 let clamped = arrow_points(
840 surface,
841 Bounds::new(point(px(0.), px(50.)), size(px(20.), px(20.))),
842 Placement::Bottom,
843 px(6.),
844 px(4.),
845 );
846 assert_eq!(clamped[0], point(px(104.), px(100.)));
847 assert_eq!(clamped[1], point(px(110.), px(94.)));
848 }
849
850 #[test]
851 fn arrow_join_covers_both_sides_of_the_surface_edge() {
852 let surface = Bounds::new(point(px(100.), px(100.)), size(px(80.), px(60.)));
853 let trigger = Bounds::new(point(px(120.), px(120.)), size(px(40.), px(20.)));
854 for (side, expected) in [
855 (
856 Placement::Bottom,
857 Bounds::new(point(px(135.), px(99.)), size(px(10.), px(2.))),
858 ),
859 (
860 Placement::Top,
861 Bounds::new(point(px(135.), px(159.)), size(px(10.), px(2.))),
862 ),
863 (
864 Placement::Right,
865 Bounds::new(point(px(99.), px(125.)), size(px(2.), px(10.))),
866 ),
867 (
868 Placement::Left,
869 Bounds::new(point(px(179.), px(125.)), size(px(2.), px(10.))),
870 ),
871 ] {
872 let points = arrow_points(surface, trigger, side, px(6.), px(4.));
873 assert_eq!(arrow_join_bounds(points, side, px(1.)), expected);
874 }
875 }
876
877 impl Render for PopoverHarness {
878 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
879 let changes = self.changes.clone();
880 Popover::new("runtime-popover")
881 .trigger(Button::new("runtime-trigger").label("Open").size(px(100.)))
882 .content(|_, _, _| {
883 div()
884 .debug_selector(|| "runtime-popover-content".into())
885 .size(px(40.))
886 })
887 .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
888 }
889 }
890
891 #[gpui::test]
892 fn pointer_open_and_outside_dismiss_use_the_base_popup_host(cx: &mut gpui::TestAppContext) {
893 cx.update(|cx| {
894 gpui_base::GlobalState::init(cx);
895 cx.set_global(Theme::default());
896 init(cx);
897 });
898
899 let changes = Rc::new(RefCell::new(Vec::new()));
900 let (_, cx) = cx.add_window_view({
901 let changes = changes.clone();
902 move |_, _| PopoverHarness { changes }
903 });
904 cx.update(|window, cx| window.draw(cx).clear(cx));
905
906 cx.simulate_click(point(px(20.), px(20.)), Default::default());
907 cx.update(|window, cx| window.draw(cx).clear(cx));
908 assert!(cx.debug_bounds("runtime-popover-content").is_some());
909
910 cx.simulate_click(point(px(300.), px(300.)), Default::default());
911 cx.update(|window, cx| window.draw(cx).clear(cx));
912 assert!(cx.debug_bounds("runtime-popover-content").is_none());
913 assert_eq!(&*changes.borrow(), &[true, false]);
916 }
917
918 struct DefaultOpenHarness;
919
920 impl Render for DefaultOpenHarness {
921 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
922 Popover::new("default-open-popover")
923 .default_open(true)
924 .trigger(Button::new("default-open-trigger").label("Open"))
925 .child(
926 div()
927 .debug_selector(|| "default-open-content".into())
928 .size(px(40.)),
929 )
930 }
931 }
932
933 #[gpui::test]
934 fn default_open_is_forwarded_to_the_base_popover(cx: &mut gpui::TestAppContext) {
935 cx.update(|cx| {
936 gpui_base::GlobalState::init(cx);
937 cx.set_global(Theme::default());
938 init(cx);
939 });
940 let (_, cx) = cx.add_window_view(|_, _| DefaultOpenHarness);
941 cx.update(|window, cx| window.draw(cx).clear(cx));
942 cx.update(|window, cx| window.draw(cx).clear(cx));
943 assert!(cx.debug_bounds("default-open-content").is_some());
944 }
945
946 struct Harness {
947 open: bool,
948 }
949
950 impl Render for Harness {
951 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
952 div().size_full().when(self.open, |this| {
953 this.child(dropdown_popup(
954 "dropdown",
955 Bounds::new(point(px(0.), px(100.)), size(px(120.), px(30.))),
956 div().debug_selector(|| "surface".into()).size(px(50.)),
957 cx,
958 ))
959 })
960 }
961 }
962
963 #[gpui::test]
968 fn the_enter_motion_starts_over_every_time_the_dropdown_opens(cx: &mut gpui::TestAppContext) {
969 cx.update(crate::init);
970 let (view, window) = cx.add_window_view(|_, _| Harness { open: true });
971
972 window.update(|window, cx| window.draw(cx).clear(cx));
973 let opening = window.debug_bounds("surface").unwrap().origin;
974
975 std::thread::sleep(DROPDOWN_ENTER_DURATION * 4);
979 window.update(|window, cx| window.draw(cx).clear(cx));
980 let settled = window.debug_bounds("surface").unwrap().origin;
981
982 assert!(
983 opening.y < settled.y,
984 "the surface should slide down into place, from {opening:?} to {settled:?}",
985 );
986
987 for open in [false, true] {
988 window.update(|window, cx| {
989 view.update(cx, |this, cx| {
990 this.open = open;
991 cx.notify();
992 });
993 window.draw(cx).clear(cx);
994 });
995 }
996
997 let reopening = window.debug_bounds("surface").unwrap().origin;
998 assert!(
999 reopening.y < settled.y,
1000 "reopening should start the motion over at {opening:?} rather than showing a \
1001 settled surface, but the first frame was already at {reopening:?}",
1002 );
1003 }
1004}