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