gpui_component/
popover.rs

1use gpui::{
2    anchored, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds, Context,
3    Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle,
4    Focusable, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, KeyBinding, LayoutId,
5    ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, Style,
6    StyleRefinement, Styled, Window,
7};
8use std::{cell::RefCell, rc::Rc};
9
10use crate::{actions::Cancel, Selectable, StyledExt as _};
11
12const CONTEXT: &str = "Popover";
13
14pub(crate) fn init(cx: &mut App) {
15    cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
16}
17
18/// The content of the popover.
19pub struct PopoverContent {
20    style: StyleRefinement,
21    focus_handle: FocusHandle,
22    content: Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
23}
24
25impl PopoverContent {
26    pub fn new<B>(_: &mut Window, cx: &mut App, content: B) -> Self
27    where
28        B: Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
29    {
30        let focus_handle = cx.focus_handle();
31
32        Self {
33            style: StyleRefinement::default(),
34            focus_handle,
35            content: Rc::new(content),
36        }
37    }
38}
39impl EventEmitter<DismissEvent> for PopoverContent {}
40
41impl Focusable for PopoverContent {
42    fn focus_handle(&self, _cx: &App) -> FocusHandle {
43        self.focus_handle.clone()
44    }
45}
46
47impl Styled for PopoverContent {
48    fn style(&mut self) -> &mut StyleRefinement {
49        &mut self.style
50    }
51}
52
53impl Render for PopoverContent {
54    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
55        div()
56            .p_2()
57            .refine_style(&self.style)
58            .track_focus(&self.focus_handle)
59            .key_context(CONTEXT)
60            .on_action(cx.listener(|_, _: &Cancel, _, cx| {
61                cx.propagate();
62                cx.emit(DismissEvent);
63            }))
64            .child(self.content.clone()(window, cx))
65    }
66}
67
68/// A popover element that can be triggered by a button or any other element.
69pub struct Popover<M: ManagedView> {
70    id: ElementId,
71    anchor: Corner,
72    trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
73    content: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
74    /// Style for trigger element.
75    /// This is used for hotfix the trigger element style to support w_full.
76    trigger_style: Option<StyleRefinement>,
77    mouse_button: MouseButton,
78    appearance: bool,
79}
80
81impl<M> Popover<M>
82where
83    M: ManagedView,
84{
85    /// Create a new Popover with `view` mode.
86    pub fn new(id: impl Into<ElementId>) -> Self {
87        Self {
88            id: id.into(),
89            anchor: Corner::TopLeft,
90            trigger: None,
91            trigger_style: None,
92            content: None,
93            mouse_button: MouseButton::Left,
94            appearance: true,
95        }
96    }
97
98    /// Set the anchor corner of the popover, default is `Corner::TopLeft`.
99    pub fn anchor(mut self, anchor: Corner) -> Self {
100        self.anchor = anchor;
101        self
102    }
103
104    /// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
105    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
106        self.mouse_button = mouse_button;
107        self
108    }
109
110    /// Set the trigger element of the popover.
111    pub fn trigger<T>(mut self, trigger: T) -> Self
112    where
113        T: Selectable + IntoElement + 'static,
114    {
115        self.trigger = Some(Box::new(|is_open, _, _| {
116            let selected = trigger.is_selected();
117            trigger.selected(selected || is_open).into_any_element()
118        }));
119        self
120    }
121
122    /// Set the style for the trigger element.
123    pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
124        self.trigger_style = Some(style);
125        self
126    }
127
128    /// Set the content of the popover.
129    ///
130    /// The `content` is a closure that returns an `AnyElement`.
131    pub fn content<C>(mut self, content: C) -> Self
132    where
133        C: Fn(&mut Window, &mut App) -> Entity<M> + 'static,
134    {
135        self.content = Some(Rc::new(content));
136        self
137    }
138
139    /// Set whether the popover no style, default is `false`.
140    ///
141    /// If no style:
142    ///
143    /// - The popover will not have a bg, border, shadow, or padding.
144    /// - The click out of the popover will not dismiss it.
145    pub fn appearance(mut self, appearance: bool) -> Self {
146        self.appearance = appearance;
147        self
148    }
149
150    fn render_trigger(&mut self, open: bool, window: &mut Window, cx: &mut App) -> AnyElement {
151        let Some(trigger) = self.trigger.take() else {
152            return div().into_any_element();
153        };
154
155        (trigger)(open, window, cx)
156    }
157
158    fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
159        bounds.corner(match self.anchor {
160            Corner::TopLeft => Corner::BottomLeft,
161            Corner::TopRight => Corner::BottomRight,
162            Corner::BottomLeft => Corner::TopLeft,
163            Corner::BottomRight => Corner::TopRight,
164        })
165    }
166
167    fn with_element_state<R>(
168        &mut self,
169        id: &GlobalElementId,
170        window: &mut Window,
171        cx: &mut App,
172        f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut Window, &mut App) -> R,
173    ) -> R {
174        window.with_optional_element_state::<PopoverElementState<M>, _>(
175            Some(id),
176            |element_state, window| {
177                let mut element_state = element_state.unwrap().unwrap_or_default();
178                let result = f(self, &mut element_state, window, cx);
179                (result, Some(element_state))
180            },
181        )
182    }
183}
184
185impl<M> IntoElement for Popover<M>
186where
187    M: ManagedView,
188{
189    type Element = Self;
190
191    fn into_element(self) -> Self::Element {
192        self
193    }
194}
195
196pub struct PopoverElementState<M> {
197    trigger_layout_id: Option<LayoutId>,
198    popover_layout_id: Option<LayoutId>,
199    popover_element: Option<AnyElement>,
200    trigger_element: Option<AnyElement>,
201    content_view: Rc<RefCell<Option<Entity<M>>>>,
202    /// Trigger bounds for positioning the popover.
203    trigger_bounds: Option<Bounds<Pixels>>,
204}
205
206impl<M> Default for PopoverElementState<M> {
207    fn default() -> Self {
208        Self {
209            trigger_layout_id: None,
210            popover_layout_id: None,
211            popover_element: None,
212            trigger_element: None,
213            content_view: Rc::new(RefCell::new(None)),
214            trigger_bounds: None,
215        }
216    }
217}
218
219pub struct PrepaintState {
220    hitbox: Hitbox,
221    /// Trigger bounds for limit a rect to handle mouse click.
222    trigger_bounds: Option<Bounds<Pixels>>,
223}
224
225impl<M: ManagedView> Element for Popover<M> {
226    type RequestLayoutState = PopoverElementState<M>;
227    type PrepaintState = PrepaintState;
228
229    fn id(&self) -> Option<ElementId> {
230        Some(self.id.clone())
231    }
232
233    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
234        None
235    }
236
237    fn request_layout(
238        &mut self,
239        id: Option<&gpui::GlobalElementId>,
240        _: Option<&gpui::InspectorElementId>,
241        window: &mut Window,
242        cx: &mut App,
243    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
244        let mut style = Style::default();
245
246        // FIXME: Remove this and find a better way to handle this.
247        // Apply trigger style, for support w_full for trigger.
248        //
249        // If remove this, the trigger will not support w_full.
250        if let Some(trigger_style) = self.trigger_style.clone() {
251            if let Some(width) = trigger_style.size.width {
252                style.size.width = width;
253            }
254            if let Some(display) = trigger_style.display {
255                style.display = display;
256            }
257        }
258
259        self.with_element_state(
260            id.unwrap(),
261            window,
262            cx,
263            |view, element_state, window, cx| {
264                let mut popover_layout_id = None;
265                let mut popover_element = None;
266                let mut is_open = false;
267
268                if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() {
269                    is_open = true;
270
271                    let mut anchored = anchored()
272                        .snap_to_window_with_margin(px(8.))
273                        .anchor(view.anchor);
274                    if let Some(trigger_bounds) = element_state.trigger_bounds {
275                        anchored = anchored.position(view.resolved_corner(trigger_bounds));
276                    }
277
278                    let mut element = {
279                        let content_view_mut = element_state.content_view.clone();
280                        let anchor = view.anchor;
281                        let appearance = view.appearance;
282                        deferred(
283                            anchored.child(
284                                div()
285                                    .size_full()
286                                    .occlude()
287                                    .tab_group()
288                                    .when(appearance, |this| this.popover_style(cx))
289                                    .map(|this| match anchor {
290                                        Corner::TopLeft | Corner::TopRight => this.top_1(),
291                                        Corner::BottomLeft | Corner::BottomRight => this.bottom_1(),
292                                    })
293                                    .child(content_view.clone())
294                                    .when(appearance, |this| {
295                                        this.on_mouse_down_out(move |_, window, _| {
296                                            // Update the element_state.content_view to `None`,
297                                            // so that the `paint`` method will not paint it.
298                                            *content_view_mut.borrow_mut() = None;
299                                            window.refresh();
300                                        })
301                                    }),
302                            ),
303                        )
304                        .with_priority(1)
305                        .into_any()
306                    };
307
308                    popover_layout_id = Some(element.request_layout(window, cx));
309                    popover_element = Some(element);
310                }
311
312                let mut trigger_element = view.render_trigger(is_open, window, cx);
313                let trigger_layout_id = trigger_element.request_layout(window, cx);
314
315                let layout_id = window.request_layout(
316                    style,
317                    Some(trigger_layout_id).into_iter().chain(popover_layout_id),
318                    cx,
319                );
320
321                (
322                    layout_id,
323                    PopoverElementState {
324                        trigger_layout_id: Some(trigger_layout_id),
325                        popover_layout_id,
326                        popover_element,
327                        trigger_element: Some(trigger_element),
328                        ..Default::default()
329                    },
330                )
331            },
332        )
333    }
334
335    fn prepaint(
336        &mut self,
337        _id: Option<&gpui::GlobalElementId>,
338        _: Option<&gpui::InspectorElementId>,
339        _bounds: gpui::Bounds<gpui::Pixels>,
340        request_layout: &mut Self::RequestLayoutState,
341        window: &mut Window,
342        cx: &mut App,
343    ) -> Self::PrepaintState {
344        if let Some(element) = &mut request_layout.trigger_element {
345            element.prepaint(window, cx);
346        }
347        if let Some(element) = &mut request_layout.popover_element {
348            element.prepaint(window, cx);
349        }
350
351        let trigger_bounds = request_layout
352            .trigger_layout_id
353            .map(|id| window.layout_bounds(id));
354
355        // Prepare the popover, for get the bounds of it for open window size.
356        let _ = request_layout
357            .popover_layout_id
358            .map(|id| window.layout_bounds(id));
359
360        let hitbox = window.insert_hitbox(
361            trigger_bounds.unwrap_or_default(),
362            gpui::HitboxBehavior::Normal,
363        );
364
365        PrepaintState {
366            trigger_bounds,
367            hitbox,
368        }
369    }
370
371    fn paint(
372        &mut self,
373        id: Option<&GlobalElementId>,
374        _: Option<&gpui::InspectorElementId>,
375        _bounds: Bounds<Pixels>,
376        request_layout: &mut Self::RequestLayoutState,
377        prepaint: &mut Self::PrepaintState,
378        window: &mut Window,
379        cx: &mut App,
380    ) {
381        self.with_element_state(
382            id.unwrap(),
383            window,
384            cx,
385            |this, element_state, window, cx| {
386                element_state.trigger_bounds = prepaint.trigger_bounds;
387
388                if let Some(mut element) = request_layout.trigger_element.take() {
389                    element.paint(window, cx);
390                }
391
392                if let Some(mut element) = request_layout.popover_element.take() {
393                    element.paint(window, cx);
394                    return;
395                }
396
397                // When mouse click down in the trigger bounds, open the popover.
398                let Some(content_build) = this.content.take() else {
399                    return;
400                };
401                let old_content_view = element_state.content_view.clone();
402                let hitbox_id = prepaint.hitbox.id;
403                let mouse_button = this.mouse_button;
404                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
405                    if phase == DispatchPhase::Bubble
406                        && event.button == mouse_button
407                        && hitbox_id.is_hovered(window)
408                    {
409                        cx.stop_propagation();
410                        window.prevent_default();
411
412                        let new_content_view = (content_build)(window, cx);
413                        let old_content_view1 = old_content_view.clone();
414
415                        let previous_focus_handle = window.focused(cx);
416
417                        window
418                            .subscribe(
419                                &new_content_view,
420                                cx,
421                                move |modal, _: &DismissEvent, window, cx| {
422                                    if modal.focus_handle(cx).contains_focused(window, cx) {
423                                        if let Some(previous_focus_handle) =
424                                            previous_focus_handle.as_ref()
425                                        {
426                                            window.focus(previous_focus_handle);
427                                        }
428                                    }
429                                    *old_content_view1.borrow_mut() = None;
430
431                                    window.refresh();
432                                },
433                            )
434                            .detach();
435
436                        window.focus(&new_content_view.focus_handle(cx));
437                        *old_content_view.borrow_mut() = Some(new_content_view);
438                        window.refresh();
439                    }
440                });
441            },
442        );
443    }
444}