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