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 _, StyleRefinement, Styled,
7    Subscription, Window, div, prelude::FluentBuilder as _,
8};
9
10use crate::{
11    DeferredPopover, GlobalState, Popup, ResolvedPosition, Selectable, StyledExt as _,
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            // Weak: the subscription is stored on this state, so a strong
106            // handle would keep the state, and its deferred-popover
107            // registration, alive after its trigger is gone.
108            let state = cx.entity().downgrade();
109            self.tracked_focus_handle
110                .clone()
111                .unwrap_or_else(|| self.focus_handle.clone())
112                .focus(window, cx);
113
114            self.dismiss_subscription =
115                Some(
116                    window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
117                        _ = state.update(cx, |state, cx| state.dismiss(window, cx));
118                        window.refresh();
119                    }),
120                );
121        } else {
122            self.dismiss_subscription = None;
123            if let Some(previous) = self.previous_focus_handle.take() {
124                if self.focus_handle.contains_focused(window, cx) {
125                    previous.focus(window, cx);
126                }
127            }
128        }
129
130        if announce && let Some(callback) = self.on_open_change.as_ref() {
131            callback(&opening, window, cx);
132        }
133        cx.notify();
134    }
135
136    #[doc(hidden)]
137    pub fn track_focus(&mut self, focus_handle: Option<FocusHandle>) {
138        self.tracked_focus_handle = focus_handle;
139    }
140
141    #[doc(hidden)]
142    pub fn set_on_open_change(&mut self, handler: Option<OpenChangeHandler>) {
143        self.on_open_change = handler;
144    }
145
146    #[doc(hidden)]
147    pub fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
148        self.dismiss(window, cx);
149    }
150}
151
152impl Focusable for PopoverState {
153    fn focus_handle(&self, _: &App) -> FocusHandle {
154        self.focus_handle.clone()
155    }
156}
157
158impl Render for PopoverState {
159    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
160        div()
161    }
162}
163
164impl EventEmitter<DismissEvent> for PopoverState {}
165
166type TriggerBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
167type ContentBuilder =
168    Box<dyn FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement>;
169
170/// An unstyled popover owning trigger interaction, open state, dismissal, and focus.
171#[derive(IntoElement)]
172pub struct Popover {
173    id: ElementId,
174    style: StyleRefinement,
175    anchor: Anchor,
176    offset: gpui::Pixels,
177    on_position: Option<Box<dyn Fn(ResolvedPosition, gpui::Bounds<gpui::Pixels>)>>,
178    default_open: bool,
179    open: Option<bool>,
180    tracked_focus_handle: Option<FocusHandle>,
181    trigger: Option<TriggerBuilder>,
182    content: Option<ContentBuilder>,
183    mouse_button: MouseButton,
184    overlay_closable: bool,
185    on_open_change: Option<OpenChangeHandler>,
186}
187
188impl Popover {
189    pub fn new(id: impl Into<ElementId>) -> Self {
190        Self {
191            id: id.into(),
192            style: StyleRefinement::default(),
193            anchor: Anchor::TopLeft,
194            offset: gpui::px(0.),
195            on_position: None,
196            default_open: false,
197            open: None,
198            tracked_focus_handle: None,
199            trigger: None,
200            content: None,
201            mouse_button: MouseButton::Left,
202            overlay_closable: true,
203            on_open_change: None,
204        }
205    }
206
207    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
208        self.anchor = anchor.into();
209        self
210    }
211
212    /// Gap from the trigger along the anchor's outward direction, zero by default.
213    pub fn offset(mut self, offset: gpui::Pixels) -> Self {
214        self.offset = offset;
215        self
216    }
217
218    /// Observe geometry to supply presentation such as a pointer arrow.
219    pub fn on_position(
220        mut self,
221        callback: impl Fn(ResolvedPosition, gpui::Bounds<gpui::Pixels>) + 'static,
222    ) -> Self {
223        self.on_position = Some(Box::new(callback));
224        self
225    }
226
227    pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
228        self.mouse_button = mouse_button;
229        self
230    }
231
232    pub fn trigger<T>(mut self, trigger: T) -> Self
233    where
234        T: Selectable + IntoElement + 'static,
235    {
236        self.trigger = Some(Box::new(|is_open, _, _| {
237            let open = trigger.is_open();
238            trigger.open(open || is_open).into_any_element()
239        }));
240        self
241    }
242
243    /// Supplies a trigger builder for higher-level presentation facades.
244    #[doc(hidden)]
245    pub fn trigger_with(
246        mut self,
247        trigger: impl FnOnce(bool, &Window, &App) -> AnyElement + 'static,
248    ) -> Self {
249        self.trigger = Some(Box::new(trigger));
250        self
251    }
252
253    pub fn default_open(mut self, open: bool) -> Self {
254        self.default_open = open;
255        self
256    }
257
258    pub fn open(mut self, open: bool) -> Self {
259        self.open = Some(open);
260        self
261    }
262
263    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
264        self.tracked_focus_handle = Some(handle.clone());
265        self
266    }
267
268    pub fn overlay_closable(mut self, closable: bool) -> Self {
269        self.overlay_closable = closable;
270        self
271    }
272
273    pub fn on_open_change(
274        mut self,
275        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
276    ) -> Self {
277        self.on_open_change = Some(Rc::new(callback));
278        self
279    }
280
281    pub fn content<F, E>(mut self, content: F) -> Self
282    where
283        E: IntoElement,
284        F: FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
285    {
286        self.content = Some(Box::new(move |state, window, cx| {
287            content(state, window, cx).into_any_element()
288        }));
289        self
290    }
291}
292
293/// Styles the trigger container: the element that takes part in the parent
294/// layout and whose bounds the popup is anchored to.
295impl Styled for Popover {
296    fn style(&mut self) -> &mut StyleRefinement {
297        &mut self.style
298    }
299}
300
301impl RenderOnce for Popover {
302    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
303        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
304            PopoverState::new(self.default_open, cx)
305        });
306        state.update(cx, |state, cx| {
307            state.track_focus(self.tracked_focus_handle);
308            state.set_on_open_change(self.on_open_change);
309            if let Some(open) = self.open {
310                state.sync_open(open, window, cx);
311            }
312        });
313
314        let open = state.read(cx).is_open();
315        let focus_handle = state.read(cx).focus_handle(cx);
316        let Some(trigger) = self.trigger else {
317            return div().id("empty").into_any_element();
318        };
319        let parent_view_id = window.current_view();
320        let popup = Popup::new(self.id, trigger(open, window, cx))
321            .refine_style(&self.style)
322            .anchor(self.anchor)
323            .offset(self.offset)
324            .when_some(self.on_position, |this, callback| {
325                this.on_position(callback)
326            })
327            .key_context(CONTEXT)
328            .on_action({
329                let state = state.clone();
330                move |_: &Confirm, window, cx| {
331                    state.update(cx, |state, cx| state.toggle_open(window, cx));
332                    cx.notify(parent_view_id);
333                }
334            })
335            .on_mouse_down(self.mouse_button, {
336                let state = state.clone();
337                move |_, window, cx| {
338                    cx.stop_propagation();
339                    state.update(cx, |state, cx| {
340                        if state.is_open() == open {
341                            state.toggle_open(window, cx);
342                        }
343                    });
344                    cx.notify(parent_view_id);
345                }
346            });
347        if !open {
348            return popup.into_any_element();
349        }
350
351        let content = div()
352            .id("content")
353            // A popover surface is a non-modal dialog: it takes focus and is
354            // dismissed with Escape, which is what this role tells assistive
355            // technology to expect.
356            .role(Role::Dialog)
357            .occlude()
358            .tab_group()
359            .track_focus(&focus_handle)
360            .key_context(CONTEXT)
361            .on_action(window.listener_for(&state, PopoverState::on_action_cancel))
362            .when_some(self.content, |this, content| {
363                this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
364            })
365            .when(self.overlay_closable, |this| {
366                this.on_mouse_down_out({
367                    let state = state.clone();
368                    move |_, window, cx| {
369                        state.update(cx, |state, cx| state.dismiss(window, cx));
370                        cx.notify(parent_view_id);
371                    }
372                })
373            });
374        popup.content(content).into_any_element()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use gpui::{AppContext as _, Context, Render, point, px};
382    use std::{cell::RefCell, rc::Rc};
383
384    /// Popover state lives in element state, which is collected as soon as it
385    /// stops being rendered — a trigger scrolled out of a virtual list, a panel
386    /// closed with its menu open. A registration that outlived it would leave
387    /// the application believing a popup is open for the rest of the session.
388    #[gpui::test]
389    fn a_state_dropped_while_open_closes_the_deferred_context(cx: &mut gpui::TestAppContext) {
390        let state = cx.update(|cx| {
391            GlobalState::init(cx);
392            let state = cx.new(|cx| PopoverState::new(false, cx));
393            state.update(cx, |state, cx| state.set_open(true, cx));
394            assert!(GlobalState::is_in_deferred_context(cx));
395            state
396        });
397
398        cx.update(|_| drop(state));
399        cx.update(|cx| assert!(!GlobalState::is_in_deferred_context(cx)));
400    }
401
402    #[gpui::test]
403    fn open_state_registers_and_unregisters_deferred_context(cx: &mut gpui::TestAppContext) {
404        cx.update(|cx| {
405            GlobalState::init(cx);
406            let state = cx.new(|cx| PopoverState::new(false, cx));
407
408            state.update(cx, |state, cx| state.set_open(true, cx));
409            assert!(state.read(cx).is_open());
410            assert!(GlobalState::is_in_deferred_context(cx));
411
412            state.update(cx, |state, cx| state.set_open(false, cx));
413            assert!(!state.read(cx).is_open());
414            assert!(!GlobalState::is_in_deferred_context(cx));
415        });
416    }
417
418    struct PopoverHarness {
419        changes: Rc<RefCell<Vec<bool>>>,
420        default_open: bool,
421    }
422
423    struct KeyboardPopoverHarness {
424        trigger_focus: FocusHandle,
425    }
426
427    impl Render for KeyboardPopoverHarness {
428        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
429            Popover::new("keyboard-popover")
430                .trigger(
431                    crate::Button::new("keyboard-trigger")
432                        .track_focus(&self.trigger_focus)
433                        .child("Open"),
434                )
435                .content(|_, _, _| {
436                    div()
437                        .debug_selector(|| "keyboard-popover-content".into())
438                        .size(px(40.))
439                })
440        }
441    }
442
443    impl Render for PopoverHarness {
444        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
445            let changes = self.changes.clone();
446            Popover::new("base-popover")
447                .default_open(self.default_open)
448                .trigger_with(|_, _, _| div().child("Open").into_any_element())
449                .content(|_, _, _| {
450                    div()
451                        .debug_selector(|| "base-popover-content".into())
452                        .size(px(40.))
453                })
454                .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
455        }
456    }
457
458    #[gpui::test]
459    fn unstyled_popover_owns_pointer_open_and_outside_dismiss(cx: &mut gpui::TestAppContext) {
460        cx.update(crate::init);
461        let changes = Rc::new(RefCell::new(Vec::new()));
462        let (_, cx) = cx.add_window_view({
463            let changes = changes.clone();
464            move |_, _| PopoverHarness {
465                changes,
466                default_open: false,
467            }
468        });
469        cx.update(|window, cx| window.draw(cx).clear(cx));
470
471        cx.simulate_click(point(px(20.), px(10.)), Default::default());
472        cx.update(|window, cx| window.draw(cx).clear(cx));
473        assert!(cx.debug_bounds("base-popover-content").is_some());
474
475        cx.simulate_click(point(px(300.), px(300.)), Default::default());
476        cx.update(|window, cx| window.draw(cx).clear(cx));
477        assert!(cx.debug_bounds("base-popover-content").is_none());
478        assert_eq!(&*changes.borrow(), &[true, false]);
479    }
480
481    #[gpui::test]
482    fn default_open_renders_content_without_activation(cx: &mut gpui::TestAppContext) {
483        cx.update(crate::init);
484        let (_, cx) = cx.add_window_view(|_, _| PopoverHarness {
485            changes: Rc::new(RefCell::new(Vec::new())),
486            default_open: true,
487        });
488        cx.update(|window, cx| window.draw(cx).clear(cx));
489        cx.update(|window, cx| window.draw(cx).clear(cx));
490        assert!(cx.debug_bounds("base-popover-content").is_some());
491    }
492
493    /// A trigger that keeps "open" and "selected" apart, the way a downstream
494    /// sidebar row does: it is selected when it is the current view, and open
495    /// only while its popover is showing.
496    #[derive(IntoElement)]
497    struct RecordingTrigger {
498        calls: Rc<RefCell<Vec<(&'static str, bool)>>>,
499        selected: bool,
500        open: bool,
501    }
502
503    impl Selectable for RecordingTrigger {
504        fn selected(mut self, selected: bool) -> Self {
505            self.calls.borrow_mut().push(("selected", selected));
506            self.selected = selected;
507            self
508        }
509
510        fn is_selected(&self) -> bool {
511            self.selected
512        }
513
514        fn open(mut self, open: bool) -> Self {
515            self.calls.borrow_mut().push(("open", open));
516            self.open = open;
517            self
518        }
519
520        fn is_open(&self) -> bool {
521            self.open
522        }
523    }
524
525    impl RenderOnce for RecordingTrigger {
526        fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
527            div().size(px(40.)).child("Open")
528        }
529    }
530
531    struct RecordingTriggerHarness {
532        calls: Rc<RefCell<Vec<(&'static str, bool)>>>,
533    }
534
535    impl Render for RecordingTriggerHarness {
536        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
537            Popover::new("recording-popover")
538                .trigger(RecordingTrigger {
539                    calls: self.calls.clone(),
540                    selected: false,
541                    open: false,
542                })
543                .content(|_, _, _| div().size(px(40.)))
544        }
545    }
546
547    #[gpui::test]
548    fn an_open_popover_tells_its_trigger_it_is_open_not_selected(cx: &mut gpui::TestAppContext) {
549        cx.update(crate::init);
550        let calls = Rc::new(RefCell::new(Vec::new()));
551        let (_, cx) = cx.add_window_view({
552            let calls = calls.clone();
553            move |_, _| RecordingTriggerHarness { calls }
554        });
555        cx.update(|window, cx| window.draw(cx).clear(cx));
556        calls.borrow_mut().clear();
557
558        cx.simulate_click(point(px(20.), px(10.)), Default::default());
559        cx.update(|window, cx| window.draw(cx).clear(cx));
560
561        let calls = calls.borrow();
562        assert!(
563            calls.contains(&("open", true)),
564            "an open popover marks its trigger open, got {calls:?}"
565        );
566        assert!(
567            !calls.iter().any(|(name, _)| *name == "selected"),
568            "opening must not touch the trigger's own selection, got {calls:?}"
569        );
570    }
571
572    #[gpui::test]
573    fn keyboard_activation_opens_the_popover(cx: &mut gpui::TestAppContext) {
574        cx.update(crate::init);
575        let (view, cx) = cx.add_window_view(|_, cx| KeyboardPopoverHarness {
576            trigger_focus: cx.focus_handle(),
577        });
578        cx.update(|window, cx| window.draw(cx).clear(cx));
579
580        cx.update(|window, cx| {
581            let focus = view.read(cx).trigger_focus.clone();
582            focus.focus(window, cx);
583        });
584        cx.simulate_keystrokes("enter");
585        cx.update(|window, cx| window.draw(cx).clear(cx));
586
587        assert!(cx.debug_bounds("keyboard-popover-content").is_some());
588    }
589}