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
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();
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#[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 pub fn offset(mut self, offset: gpui::Pixels) -> Self {
209 self.offset = offset;
210 self
211 }
212
213 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 #[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 .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 #[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 #[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}