Skip to main content

gpui_cn/
popover.rs

1//! A floating panel under a trigger, as shadcn's `Popover`.
2
3use std::{cell::Cell, rc::Rc};
4
5use gpui_kit::{
6    AnyElement, App, Bounds, ElementId, FocusHandle, InteractiveElement as _, IntoElement,
7    ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
8    base::{
9        self, Align, ElementExt as _, Placement, Selectable, StyledExt as _, TestSupportExt as _,
10    },
11    div,
12};
13
14use crate::{
15    ActiveTheme as _,
16    menu::{MenuLook, MenuMotion, corner, placement},
17};
18
19type TriggerBuilder = Box<dyn FnOnce(bool) -> AnyElement>;
20type ContentBuilder = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
21type OpenChange = Box<dyn Fn(bool, &mut Window, &mut App)>;
22
23/// A panel of any content that opens under a trigger, as shadcn's
24/// `Popover`.
25///
26/// `gpui-base` owns the behavior: a press on the trigger, or Enter or
27/// Space while it has focus, opens and closes it; Escape and a press
28/// outside close it; focus moves into the panel and back to the trigger.
29/// This component draws the panel as a menu's: the popover surface, its
30/// hairline, the large radius, the shadow, and the menu's entrance. The
31/// panel opens a menu gap under the trigger, lined up with its leading
32/// edge unless [`align`](Self::align) says otherwise, and holds the
33/// menu's inset around the content.
34///
35/// ```
36/// use gpui_cn::{Button, Popover};
37/// use gpui_kit::{IntoElement, ParentElement as _, div};
38///
39/// fn details() -> impl IntoElement {
40///     Popover::new("details")
41///         .trigger(Button::new("details-trigger").label("Details"))
42///         .content(|_, _| div().child("Built on Tuesday"))
43/// }
44/// ```
45///
46/// Without [`open`](Self::open) the popover keeps its own open state,
47/// keyed by the id, which must be stable across frames.
48#[derive(IntoElement)]
49pub struct Popover {
50    id: ElementId,
51    trigger: Option<TriggerBuilder>,
52    content: Option<ContentBuilder>,
53    align: Align,
54    open: Option<bool>,
55    on_open_change: Option<OpenChange>,
56    focus: Option<FocusHandle>,
57    style: StyleRefinement,
58}
59
60impl Popover {
61    /// A closed popover with a stable id.
62    pub fn new(id: impl Into<ElementId>) -> Self {
63        Self {
64            id: id.into(),
65            trigger: None,
66            content: None,
67            align: Align::Start,
68            open: None,
69            on_open_change: None,
70            focus: None,
71            style: StyleRefinement::default(),
72        }
73    }
74
75    /// The element that opens the popover, such as a [`Button`](crate::Button)
76    /// without a click handler of its own. It shows as selected while the
77    /// popover is open.
78    pub fn trigger(mut self, trigger: impl Selectable + IntoElement + 'static) -> Self {
79        self.trigger = Some(Box::new(move |open| trigger.open(open).into_any_element()));
80        self
81    }
82
83    /// Builds the panel's content each frame it is open.
84    pub fn content<E: IntoElement>(
85        mut self,
86        content: impl FnOnce(&mut Window, &mut App) -> E + 'static,
87    ) -> Self {
88        self.content = Some(Box::new(move |window, cx| {
89            content(window, cx).into_any_element()
90        }));
91        self
92    }
93
94    /// Which edge of the trigger the panel lines up with. The default is
95    /// `Start`.
96    pub fn align(mut self, align: Align) -> Self {
97        self.align = align;
98        self
99    }
100
101    /// Opens or closes the popover from the application's own state. The
102    /// trigger, Escape, and an outside press report through
103    /// [`on_open_change`](Self::on_open_change), which should update it.
104    pub fn open(mut self, open: bool) -> Self {
105        self.open = Some(open);
106        self
107    }
108
109    /// Called when the trigger, Escape, or an outside press opens or
110    /// closes the popover.
111    pub fn on_open_change(
112        mut self,
113        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
114    ) -> Self {
115        self.on_open_change = Some(Box::new(handler));
116        self
117    }
118
119    /// The element that takes focus when the popover opens, such as a
120    /// search field inside it. Without one the panel itself takes it.
121    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
122        self.focus = Some(focus.clone());
123        self
124    }
125}
126
127/// Styles the panel.
128impl Styled for Popover {
129    fn style(&mut self) -> &mut StyleRefinement {
130        &mut self.style
131    }
132}
133
134impl RenderOnce for Popover {
135    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
136        let look = MenuLook::of(cx.theme(), window.rem_size());
137        // Where the trigger was last laid out, which decides the side.
138        let trigger_bounds = window
139            .use_keyed_state(
140                ElementId::NamedChild(self.id.clone().into(), "trigger-bounds".into()),
141                cx,
142                |_, _| Rc::new(Cell::new(Bounds::default())),
143            )
144            .read(cx)
145            .clone();
146        let side = placement(
147            trigger_bounds.get(),
148            window.viewport_size().height,
149            look.max_height,
150            look.gap,
151        );
152        let opens_above = side == Placement::Top;
153        let (anchor, _) = corner(side, self.align, trigger_bounds.get(), look.gap);
154        let panel_id = ElementId::NamedChild(self.id.clone().into(), "panel".into());
155        let content = self.content;
156        let style = self.style;
157        let mut popover = base::Popover::new(self.id)
158            .anchor(anchor)
159            .offset(look.gap)
160            .trigger_with(move |open, _, _| {
161                div()
162                    .relative()
163                    .on_prepaint(move |bounds, _, _| trigger_bounds.set(bounds))
164                    .children(self.trigger.map(|trigger| trigger(open)))
165                    .into_any_element()
166            })
167            .content(move |_, window, cx| {
168                let Some(motion) = MenuMotion::sample(
169                    ElementId::NamedChild(panel_id.clone().into(), "presence".into()),
170                    true,
171                    true,
172                    &look,
173                    window,
174                    cx,
175                ) else {
176                    return div().into_any_element();
177                };
178                div()
179                    .id(panel_id)
180                    .test_support()
181                    .relative()
182                    // A panel above its trigger slides up into place.
183                    .top(if opens_above {
184                        -motion.offset
185                    } else {
186                        motion.offset
187                    })
188                    .opacity(motion.opacity)
189                    .p(look.padding)
190                    .rounded(look.radius)
191                    .bg(look.surface)
192                    .border_1()
193                    .border_color(look.border)
194                    .shadow(look.shadow(motion.shadow_strength))
195                    .text_size(look.text_size)
196                    .line_height(look.line_height)
197                    .text_color(look.foreground)
198                    .refine_style(&style)
199                    .children(content.map(|content| content(window, cx)))
200                    .into_any_element()
201            });
202        if let Some(open) = self.open {
203            popover = popover.open(open);
204        }
205        if let Some(handler) = self.on_open_change {
206            popover = popover.on_open_change(move |open, window, cx| handler(*open, window, cx));
207        }
208        if let Some(focus) = &self.focus {
209            popover = popover.track_focus(focus);
210        }
211        popover
212    }
213}