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 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
34pub 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 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 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#[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 pub fn offset(mut self, offset: gpui::Pixels) -> Self {
225 self.offset = offset;
226 self
227 }
228
229 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 #[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
304impl 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 .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 #[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 #[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}