Skip to main content

gpui_base/
popover.rs

1use std::rc::Rc;
2
3use gpui::{
4    Anchor, AnyElement, App, Context, DismissEvent, ElementId, EventEmitter, FocusHandle,
5    Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _,
6    Render, RenderOnce, Role, StatefulInteractiveElement as _, Subscription, Window, div,
7    prelude::FluentBuilder as _,
8};
9
10use crate::{
11    DeferredPopover, GlobalState, Popup, Selectable,
12    actions::{Cancel, Confirm},
13};
14
15const CONTEXT: &str = "Popover";
16
17pub(crate) fn init(cx: &mut App) {
18    cx.bind_keys([
19        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
20        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
21        KeyBinding::new("space", Confirm { secondary: false }, Some(CONTEXT)),
22    ]);
23}
24
25type OpenChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
26
27/// State and focus lifecycle for an unstyled popover.
28///
29/// Applications own trigger/content construction, positioning, appearance, and
30/// animation. This state owns the controlled open lifecycle, dismissal, focus
31/// capture and restoration, and deferred-popup registration.
32pub struct PopoverState {
33    focus_handle: FocusHandle,
34    tracked_focus_handle: Option<FocusHandle>,
35    previous_focus_handle: Option<FocusHandle>,
36    open: bool,
37    on_open_change: Option<OpenChangeHandler>,
38    dismiss_subscription: Option<Subscription>,
39    /// Held while open, so that a state collected without being closed — this
40    /// lives in element state — takes its registration with it.
41    deferred_context: Option<DeferredPopover>,
42}
43
44impl PopoverState {
45    pub fn new(default_open: bool, cx: &mut App) -> Self {
46        Self {
47            focus_handle: cx.focus_handle(),
48            tracked_focus_handle: None,
49            previous_focus_handle: None,
50            open: default_open,
51            on_open_change: None,
52            dismiss_subscription: None,
53            deferred_context: None,
54        }
55    }
56
57    pub fn is_open(&self) -> bool {
58        self.open
59    }
60
61    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
62        if self.open {
63            self.toggle_open(window, cx);
64        }
65    }
66
67    pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
68        if !self.open {
69            self.toggle_open(window, cx);
70        }
71    }
72
73    #[doc(hidden)]
74    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
75        self.open = open;
76        self.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
77    }
78
79    #[doc(hidden)]
80    pub fn sync_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
81        self.transition_to(open, false, window, cx);
82    }
83
84    #[doc(hidden)]
85    pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
86        self.transition_to(!self.open, true, window, cx);
87    }
88
89    fn transition_to(
90        &mut self,
91        opening: bool,
92        announce: bool,
93        window: &mut Window,
94        cx: &mut Context<Self>,
95    ) {
96        if self.open == opening {
97            return;
98        }
99        if opening {
100            self.previous_focus_handle = window.focused(cx);
101        }
102        self.set_open(opening, cx);
103
104        if self.open {
105            let state = cx.entity();
106            self.tracked_focus_handle
107                .clone()
108                .unwrap_or_else(|| self.focus_handle.clone())
109                .focus(window, cx);
110
111            self.dismiss_subscription =
112                Some(
113                    window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
114                        state.update(cx, |state, cx| state.dismiss(window, cx));
115                        window.refresh();
116                    }),
117                );
118        } else {
119            self.dismiss_subscription = None;
120            if let Some(previous) = self.previous_focus_handle.take() {
121                if self.focus_handle.contains_focused(window, cx) {
122                    previous.focus(window, cx);
123                }
124            }
125        }
126
127        if announce && let Some(callback) = self.on_open_change.as_ref() {
128            callback(&opening, window, cx);
129        }
130        cx.notify();
131    }
132
133    #[doc(hidden)]
134    pub fn track_focus(&mut self, focus_handle: Option<FocusHandle>) {
135        self.tracked_focus_handle = focus_handle;
136    }
137
138    #[doc(hidden)]
139    pub fn set_on_open_change(&mut self, handler: Option<OpenChangeHandler>) {
140        self.on_open_change = handler;
141    }
142
143    #[doc(hidden)]
144    pub fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
145        self.dismiss(window, cx);
146    }
147}
148
149impl Focusable for PopoverState {
150    fn focus_handle(&self, _: &App) -> FocusHandle {
151        self.focus_handle.clone()
152    }
153}
154
155impl Render for PopoverState {
156    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
157        div()
158    }
159}
160
161impl EventEmitter<DismissEvent> for PopoverState {}
162
163type TriggerBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
164type ContentBuilder =
165    Box<dyn FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement>;
166
167/// An unstyled popover owning trigger interaction, open state, dismissal, and focus.
168#[derive(IntoElement)]
169pub struct Popover {
170    id: ElementId,
171    anchor: Anchor,
172    default_open: bool,
173    open: Option<bool>,
174    tracked_focus_handle: Option<FocusHandle>,
175    trigger: Option<TriggerBuilder>,
176    content: Option<ContentBuilder>,
177    mouse_button: MouseButton,
178    overlay_closable: bool,
179    on_open_change: Option<OpenChangeHandler>,
180}
181
182impl Popover {
183    pub fn new(id: impl Into<ElementId>) -> Self {
184        Self {
185            id: id.into(),
186            anchor: Anchor::TopLeft,
187            default_open: false,
188            open: None,
189            tracked_focus_handle: None,
190            trigger: None,
191            content: None,
192            mouse_button: MouseButton::Left,
193            overlay_closable: true,
194            on_open_change: None,
195        }
196    }
197
198    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
199        self.anchor = anchor.into();
200        self
201    }
202
203    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
204        self.mouse_button = mouse_button;
205        self
206    }
207
208    pub fn trigger<T>(mut self, trigger: T) -> Self
209    where
210        T: Selectable + IntoElement + 'static,
211    {
212        self.trigger = Some(Box::new(|is_open, _, _| {
213            let selected = trigger.is_selected();
214            trigger.selected(selected || is_open).into_any_element()
215        }));
216        self
217    }
218
219    /// Supplies a trigger builder for higher-level presentation facades.
220    #[doc(hidden)]
221    pub fn trigger_with(
222        mut self,
223        trigger: impl FnOnce(bool, &Window, &App) -> AnyElement + 'static,
224    ) -> Self {
225        self.trigger = Some(Box::new(trigger));
226        self
227    }
228
229    pub fn default_open(mut self, open: bool) -> Self {
230        self.default_open = open;
231        self
232    }
233
234    pub fn open(mut self, open: bool) -> Self {
235        self.open = Some(open);
236        self
237    }
238
239    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
240        self.tracked_focus_handle = Some(handle.clone());
241        self
242    }
243
244    pub fn overlay_closable(mut self, closable: bool) -> Self {
245        self.overlay_closable = closable;
246        self
247    }
248
249    pub fn on_open_change(
250        mut self,
251        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
252    ) -> Self {
253        self.on_open_change = Some(Rc::new(callback));
254        self
255    }
256
257    pub fn content<F, E>(mut self, content: F) -> Self
258    where
259        E: IntoElement,
260        F: FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
261    {
262        self.content = Some(Box::new(move |state, window, cx| {
263            content(state, window, cx).into_any_element()
264        }));
265        self
266    }
267}
268
269impl RenderOnce for Popover {
270    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
271        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
272            PopoverState::new(self.default_open, cx)
273        });
274        state.update(cx, |state, cx| {
275            state.track_focus(self.tracked_focus_handle);
276            state.set_on_open_change(self.on_open_change);
277            if let Some(open) = self.open {
278                state.sync_open(open, window, cx);
279            }
280        });
281
282        let open = state.read(cx).is_open();
283        let focus_handle = state.read(cx).focus_handle(cx);
284        let Some(trigger) = self.trigger else {
285            return div().id("empty").into_any_element();
286        };
287        let parent_view_id = window.current_view();
288        let popup = Popup::new(self.id, trigger(open, window, cx))
289            .anchor(self.anchor)
290            .key_context(CONTEXT)
291            .on_action({
292                let state = state.clone();
293                move |_: &Confirm, window, cx| {
294                    state.update(cx, |state, cx| state.toggle_open(window, cx));
295                    cx.notify(parent_view_id);
296                }
297            })
298            .on_mouse_down(self.mouse_button, {
299                let state = state.clone();
300                move |_, window, cx| {
301                    cx.stop_propagation();
302                    state.update(cx, |state, cx| {
303                        if state.is_open() == open {
304                            state.toggle_open(window, cx);
305                        }
306                    });
307                    cx.notify(parent_view_id);
308                }
309            });
310        if !open {
311            return popup.into_any_element();
312        }
313
314        let content = div()
315            .id("content")
316            // A popover surface is a non-modal dialog: it takes focus and is
317            // dismissed with Escape, which is what this role tells assistive
318            // technology to expect.
319            .role(Role::Dialog)
320            .occlude()
321            .tab_group()
322            .track_focus(&focus_handle)
323            .key_context(CONTEXT)
324            .on_action(window.listener_for(&state, PopoverState::on_action_cancel))
325            .when_some(self.content, |this, content| {
326                this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
327            })
328            .when(self.overlay_closable, |this| {
329                this.on_mouse_down_out({
330                    let state = state.clone();
331                    move |_, window, cx| {
332                        state.update(cx, |state, cx| state.dismiss(window, cx));
333                        cx.notify(parent_view_id);
334                    }
335                })
336            });
337        popup.content(content).into_any_element()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use gpui::{AppContext as _, Context, Render, Styled as _, point, px};
345    use std::{cell::RefCell, rc::Rc};
346
347    /// Popover state lives in element state, which is collected as soon as it
348    /// stops being rendered — a trigger scrolled out of a virtual list, a panel
349    /// closed with its menu open. A registration that outlived it would leave
350    /// the application believing a popup is open for the rest of the session.
351    #[gpui::test]
352    fn a_state_dropped_while_open_closes_the_deferred_context(cx: &mut gpui::TestAppContext) {
353        let state = cx.update(|cx| {
354            GlobalState::init(cx);
355            let state = cx.new(|cx| PopoverState::new(false, cx));
356            state.update(cx, |state, cx| state.set_open(true, cx));
357            assert!(GlobalState::is_in_deferred_context(cx));
358            state
359        });
360
361        cx.update(|_| drop(state));
362        cx.update(|cx| assert!(!GlobalState::is_in_deferred_context(cx)));
363    }
364
365    #[gpui::test]
366    fn open_state_registers_and_unregisters_deferred_context(cx: &mut gpui::TestAppContext) {
367        cx.update(|cx| {
368            GlobalState::init(cx);
369            let state = cx.new(|cx| PopoverState::new(false, cx));
370
371            state.update(cx, |state, cx| state.set_open(true, cx));
372            assert!(state.read(cx).is_open());
373            assert!(GlobalState::is_in_deferred_context(cx));
374
375            state.update(cx, |state, cx| state.set_open(false, cx));
376            assert!(!state.read(cx).is_open());
377            assert!(!GlobalState::is_in_deferred_context(cx));
378        });
379    }
380
381    struct PopoverHarness {
382        changes: Rc<RefCell<Vec<bool>>>,
383        default_open: bool,
384    }
385
386    struct KeyboardPopoverHarness {
387        trigger_focus: FocusHandle,
388    }
389
390    impl Render for KeyboardPopoverHarness {
391        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
392            Popover::new("keyboard-popover")
393                .trigger(
394                    crate::Button::new("keyboard-trigger")
395                        .track_focus(&self.trigger_focus)
396                        .child("Open"),
397                )
398                .content(|_, _, _| {
399                    div()
400                        .debug_selector(|| "keyboard-popover-content".into())
401                        .size(px(40.))
402                })
403        }
404    }
405
406    impl Render for PopoverHarness {
407        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
408            let changes = self.changes.clone();
409            Popover::new("base-popover")
410                .default_open(self.default_open)
411                .trigger_with(|_, _, _| div().child("Open").into_any_element())
412                .content(|_, _, _| {
413                    div()
414                        .debug_selector(|| "base-popover-content".into())
415                        .size(px(40.))
416                })
417                .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
418        }
419    }
420
421    #[gpui::test]
422    fn unstyled_popover_owns_pointer_open_and_outside_dismiss(cx: &mut gpui::TestAppContext) {
423        cx.update(crate::init);
424        let changes = Rc::new(RefCell::new(Vec::new()));
425        let (_, cx) = cx.add_window_view({
426            let changes = changes.clone();
427            move |_, _| PopoverHarness {
428                changes,
429                default_open: false,
430            }
431        });
432        cx.update(|window, cx| window.draw(cx).clear(cx));
433
434        cx.simulate_click(point(px(20.), px(10.)), Default::default());
435        cx.update(|window, cx| window.draw(cx).clear(cx));
436        assert!(cx.debug_bounds("base-popover-content").is_some());
437
438        cx.simulate_click(point(px(300.), px(300.)), Default::default());
439        cx.update(|window, cx| window.draw(cx).clear(cx));
440        assert!(cx.debug_bounds("base-popover-content").is_none());
441        assert_eq!(&*changes.borrow(), &[true, false]);
442    }
443
444    #[gpui::test]
445    fn default_open_renders_content_without_activation(cx: &mut gpui::TestAppContext) {
446        cx.update(crate::init);
447        let (_, cx) = cx.add_window_view(|_, _| PopoverHarness {
448            changes: Rc::new(RefCell::new(Vec::new())),
449            default_open: true,
450        });
451        cx.update(|window, cx| window.draw(cx).clear(cx));
452        cx.update(|window, cx| window.draw(cx).clear(cx));
453        assert!(cx.debug_bounds("base-popover-content").is_some());
454    }
455
456    #[gpui::test]
457    fn keyboard_activation_opens_the_popover(cx: &mut gpui::TestAppContext) {
458        cx.update(crate::init);
459        let (view, cx) = cx.add_window_view(|_, cx| KeyboardPopoverHarness {
460            trigger_focus: cx.focus_handle(),
461        });
462        cx.update(|window, cx| window.draw(cx).clear(cx));
463
464        cx.update(|window, cx| {
465            let focus = view.read(cx).trigger_focus.clone();
466            focus.focus(window, cx);
467        });
468        cx.simulate_keystrokes("enter");
469        cx.update(|window, cx| window.draw(cx).clear(cx));
470
471        assert!(cx.debug_bounds("keyboard-popover-content").is_some());
472    }
473}