gpui_component/
popover.rs

1use gpui::{
2    anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds,
3    Context, Corner, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable,
4    InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
5    Render, RenderOnce, StyleRefinement, Styled, Subscription, Window,
6};
7use std::rc::Rc;
8
9use crate::{actions::Cancel, v_flex, Selectable, StyledExt as _};
10
11const CONTEXT: &str = "Popover";
12pub(crate) fn init(cx: &mut App) {
13    cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
14}
15
16/// A popover element that can be triggered by a button or any other element.
17#[derive(IntoElement)]
18pub struct Popover {
19    id: ElementId,
20    style: StyleRefinement,
21    anchor: Corner,
22    default_open: bool,
23    open: Option<bool>,
24    tracked_focus_handle: Option<FocusHandle>,
25    trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
26    content: Option<
27        Rc<
28            dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
29                + 'static,
30        >,
31    >,
32    children: Vec<AnyElement>,
33    /// Style for trigger element.
34    /// This is used for hotfix the trigger element style to support w_full.
35    trigger_style: Option<StyleRefinement>,
36    mouse_button: MouseButton,
37    appearance: bool,
38    overlay_closable: bool,
39    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
40}
41
42impl Popover {
43    /// Create a new Popover with `view` mode.
44    pub fn new(id: impl Into<ElementId>) -> Self {
45        Self {
46            id: id.into(),
47            style: StyleRefinement::default(),
48            anchor: Corner::TopLeft,
49            trigger: None,
50            trigger_style: None,
51            content: None,
52            tracked_focus_handle: None,
53            children: vec![],
54            mouse_button: MouseButton::Left,
55            appearance: true,
56            overlay_closable: true,
57            default_open: false,
58            open: None,
59            on_open_change: None,
60        }
61    }
62
63    /// Set the anchor corner of the popover, default is `Corner::TopLeft`.
64    pub fn anchor(mut self, anchor: Corner) -> Self {
65        self.anchor = anchor;
66        self
67    }
68
69    /// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
70    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
71        self.mouse_button = mouse_button;
72        self
73    }
74
75    /// Set the trigger element of the popover.
76    pub fn trigger<T>(mut self, trigger: T) -> Self
77    where
78        T: Selectable + IntoElement + 'static,
79    {
80        self.trigger = Some(Box::new(|is_open, _, _| {
81            let selected = trigger.is_selected();
82            trigger.selected(selected || is_open).into_any_element()
83        }));
84        self
85    }
86
87    /// Set the default open state of the popover, default is `false`.
88    ///
89    /// This is only used to initialize the open state of the popover.
90    ///
91    /// And please note that if you use the `open` method, this value will be ignored.
92    pub fn default_open(mut self, open: bool) -> Self {
93        self.default_open = open;
94        self
95    }
96
97    /// Force set the open state of the popover.
98    ///
99    /// If this is set, the popover will be controlled by this value.
100    ///
101    /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
102    pub fn open(mut self, open: bool) -> Self {
103        self.open = Some(open);
104        self
105    }
106
107    /// Add a callback to be called when the open state changes.
108    ///
109    /// The first `&bool` parameter is the **new open state**.
110    ///
111    /// This is useful when using the `open` method to control the popover state.
112    pub fn on_open_change<F>(mut self, callback: F) -> Self
113    where
114        F: Fn(&bool, &mut Window, &mut App) + 'static,
115    {
116        self.on_open_change = Some(Rc::new(callback));
117        self
118    }
119
120    /// Set the style for the trigger element.
121    pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
122        self.trigger_style = Some(style);
123        self
124    }
125
126    /// Set whether clicking outside the popover will dismiss it, default is `true`.
127    pub fn overlay_closable(mut self, closable: bool) -> Self {
128        self.overlay_closable = closable;
129        self
130    }
131
132    /// Set the content builder for content of the Popover.
133    ///
134    /// This callback will called every time on render the popover.
135    /// So, you should avoid creating new elements or entities in the content closure.
136    pub fn content<F, E>(mut self, content: F) -> Self
137    where
138        E: IntoElement,
139        F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
140    {
141        self.content = Some(Rc::new(move |state, window, cx| {
142            content(state, window, cx).into_any_element()
143        }));
144        self
145    }
146
147    /// Set whether the popover no style, default is `false`.
148    ///
149    /// If no style:
150    ///
151    /// - The popover will not have a bg, border, shadow, or padding.
152    /// - The click out of the popover will not dismiss it.
153    pub fn appearance(mut self, appearance: bool) -> Self {
154        self.appearance = appearance;
155        self
156    }
157
158    /// Bind the focus handle to receive focus when the popover is opened.
159    /// If you not set this, a new focus handle will be created for the popover to
160    ///
161    /// If popover is opened, the focus will be moved to the focus handle.
162    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
163        self.tracked_focus_handle = Some(handle.clone());
164        self
165    }
166
167    fn resolved_corner(anchor: Corner, bounds: Bounds<Pixels>) -> Point<Pixels> {
168        bounds.corner(match anchor {
169            Corner::TopLeft => Corner::BottomLeft,
170            Corner::TopRight => Corner::BottomRight,
171            Corner::BottomLeft => Corner::TopLeft,
172            Corner::BottomRight => Corner::TopRight,
173        }) + Point {
174            x: px(0.),
175            y: -bounds.size.height,
176        }
177    }
178}
179
180impl ParentElement for Popover {
181    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
182        self.children.extend(elements);
183    }
184}
185
186impl Styled for Popover {
187    fn style(&mut self) -> &mut StyleRefinement {
188        &mut self.style
189    }
190}
191
192pub struct PopoverState {
193    focus_handle: FocusHandle,
194    pub(crate) tracked_focus_handle: Option<FocusHandle>,
195    trigger_bounds: Option<Bounds<Pixels>>,
196    previous_focus: Option<FocusHandle>,
197    open: bool,
198    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
199
200    _dismiss_subscription: Option<Subscription>,
201}
202
203impl PopoverState {
204    pub fn new(default_open: bool, cx: &mut App) -> Self {
205        Self {
206            focus_handle: cx.focus_handle(),
207            tracked_focus_handle: None,
208            trigger_bounds: None,
209            previous_focus: None,
210            open: default_open,
211            on_open_change: None,
212            _dismiss_subscription: None,
213        }
214    }
215
216    /// Check if the popover is open.
217    pub fn is_open(&self) -> bool {
218        self.open
219    }
220
221    /// Dismiss the popover if it is open.
222    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
223        if self.open {
224            self.toggle_open(window, cx);
225        }
226    }
227
228    /// Open the popover if it is closed.
229    pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
230        if !self.open {
231            self.toggle_open(window, cx);
232        }
233    }
234
235    fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
236        self.open = !self.open;
237        if self.open {
238            let state = cx.entity();
239            self.previous_focus = window.focused(cx);
240            let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
241            {
242                tracked_focus_handle
243            } else {
244                self.focus_handle.clone()
245            };
246
247            cx.defer_in(window, move |_, window, _| {
248                focus_handle.focus(window);
249            });
250
251            self._dismiss_subscription =
252                Some(
253                    window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
254                        state.update(cx, |state, cx| {
255                            state.dismiss(window, cx);
256                        });
257                        window.refresh();
258                    }),
259                );
260        } else {
261            if let Some(previous_focus) = self.previous_focus.take() {
262                window.focus(&previous_focus);
263            }
264            self._dismiss_subscription = None;
265        }
266
267        if let Some(callback) = self.on_open_change.as_ref() {
268            callback(&self.open, window, cx);
269        }
270        cx.notify();
271    }
272
273    fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
274        self.dismiss(window, cx);
275    }
276}
277
278impl Focusable for PopoverState {
279    fn focus_handle(&self, _: &App) -> FocusHandle {
280        self.focus_handle.clone()
281    }
282}
283
284impl Render for PopoverState {
285    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
286        div()
287    }
288}
289
290impl EventEmitter<DismissEvent> for PopoverState {}
291
292impl RenderOnce for Popover {
293    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
294        let force_open = self.open;
295        let default_open = self.default_open;
296        let tracked_focus_handle = self.tracked_focus_handle.clone();
297        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
298            PopoverState::new(default_open, cx)
299        });
300
301        state.update(cx, |state, _| {
302            if let Some(tracked_focus_handle) = tracked_focus_handle {
303                state.tracked_focus_handle = Some(tracked_focus_handle);
304            }
305            state.on_open_change = self.on_open_change.clone();
306            if let Some(force_open) = force_open {
307                state.open = force_open;
308            }
309        });
310
311        let open = state.read(cx).open;
312        let focus_handle = state.focus_handle(cx);
313        let trigger_bounds = state.read(cx).trigger_bounds;
314
315        let Some(trigger) = self.trigger else {
316            return div().id("empty");
317        };
318
319        let parent_view_id = window.current_view();
320
321        let el = div()
322            .id(self.id)
323            .child((trigger)(open, window, cx))
324            .on_mouse_down(self.mouse_button, {
325                let state = state.clone();
326                move |_, window, cx| {
327                    state.update(cx, |state, cx| {
328                        // We force set open to false to toggle it correctly.
329                        // Because if the mouse down out will toggle open first.
330                        state.open = open;
331                        state.toggle_open(window, cx);
332                    });
333                    cx.notify(parent_view_id);
334                }
335            })
336            .child(
337                canvas(
338                    {
339                        let state = state.clone();
340                        move |bounds, _, cx| {
341                            state.update(cx, |state, _| {
342                                state.trigger_bounds = Some(bounds);
343                            })
344                        }
345                    },
346                    |_, _, _, _| {},
347                )
348                .absolute()
349                .size_full(),
350            );
351
352        if !open {
353            return el;
354        }
355
356        el.child(
357            deferred(
358                anchored()
359                    .snap_to_window_with_margin(px(8.))
360                    .anchor(self.anchor)
361                    .when_some(trigger_bounds, |this, trigger_bounds| {
362                        this.position(Self::resolved_corner(self.anchor, trigger_bounds))
363                    })
364                    .child(
365                        v_flex()
366                            .id("content")
367                            .key_context(CONTEXT)
368                            .track_focus(&focus_handle)
369                            .on_action(window.listener_for(&state, PopoverState::on_action_cancel))
370                            .size_full()
371                            .occlude()
372                            .tab_group()
373                            .when(self.appearance, |this| this.popover_style(cx).p_3())
374                            .map(|this| match self.anchor {
375                                Corner::TopLeft | Corner::TopRight => this.top_1(),
376                                Corner::BottomLeft | Corner::BottomRight => this.bottom_1(),
377                            })
378                            .when_some(self.content, |this, content| {
379                                this.child(
380                                    state.update(cx, |state, cx| (content)(state, window, cx)),
381                                )
382                            })
383                            .children(self.children)
384                            .when(self.overlay_closable, |this| {
385                                this.on_mouse_down_out({
386                                    let state = state.clone();
387                                    move |_, window, cx| {
388                                        state.update(cx, |state, cx| {
389                                            state.dismiss(window, cx);
390                                        });
391                                        cx.notify(parent_view_id);
392                                    }
393                                })
394                            })
395                            .refine_style(&self.style),
396                    ),
397            )
398            .with_priority(1),
399        )
400    }
401}