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