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