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