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 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 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#[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 pub fn offset(mut self, offset: gpui::Pixels) -> Self {
230 self.offset = offset;
231 self
232 }
233
234 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 #[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
309impl 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 .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 #[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 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 #[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}