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, 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 default_open: bool,
173 open: Option<bool>,
174 tracked_focus_handle: Option<FocusHandle>,
175 trigger: Option<TriggerBuilder>,
176 content: Option<ContentBuilder>,
177 mouse_button: MouseButton,
178 overlay_closable: bool,
179 on_open_change: Option<OpenChangeHandler>,
180}
181
182impl Popover {
183 pub fn new(id: impl Into<ElementId>) -> Self {
184 Self {
185 id: id.into(),
186 anchor: Anchor::TopLeft,
187 default_open: false,
188 open: None,
189 tracked_focus_handle: None,
190 trigger: None,
191 content: None,
192 mouse_button: MouseButton::Left,
193 overlay_closable: true,
194 on_open_change: None,
195 }
196 }
197
198 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
199 self.anchor = anchor.into();
200 self
201 }
202
203 pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
204 self.mouse_button = mouse_button;
205 self
206 }
207
208 pub fn trigger<T>(mut self, trigger: T) -> Self
209 where
210 T: Selectable + IntoElement + 'static,
211 {
212 self.trigger = Some(Box::new(|is_open, _, _| {
213 let selected = trigger.is_selected();
214 trigger.selected(selected || is_open).into_any_element()
215 }));
216 self
217 }
218
219 #[doc(hidden)]
221 pub fn trigger_with(
222 mut self,
223 trigger: impl FnOnce(bool, &Window, &App) -> AnyElement + 'static,
224 ) -> Self {
225 self.trigger = Some(Box::new(trigger));
226 self
227 }
228
229 pub fn default_open(mut self, open: bool) -> Self {
230 self.default_open = open;
231 self
232 }
233
234 pub fn open(mut self, open: bool) -> Self {
235 self.open = Some(open);
236 self
237 }
238
239 pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
240 self.tracked_focus_handle = Some(handle.clone());
241 self
242 }
243
244 pub fn overlay_closable(mut self, closable: bool) -> Self {
245 self.overlay_closable = closable;
246 self
247 }
248
249 pub fn on_open_change(
250 mut self,
251 callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
252 ) -> Self {
253 self.on_open_change = Some(Rc::new(callback));
254 self
255 }
256
257 pub fn content<F, E>(mut self, content: F) -> Self
258 where
259 E: IntoElement,
260 F: FnOnce(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
261 {
262 self.content = Some(Box::new(move |state, window, cx| {
263 content(state, window, cx).into_any_element()
264 }));
265 self
266 }
267}
268
269impl RenderOnce for Popover {
270 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
271 let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
272 PopoverState::new(self.default_open, cx)
273 });
274 state.update(cx, |state, cx| {
275 state.track_focus(self.tracked_focus_handle);
276 state.set_on_open_change(self.on_open_change);
277 if let Some(open) = self.open {
278 state.sync_open(open, window, cx);
279 }
280 });
281
282 let open = state.read(cx).is_open();
283 let focus_handle = state.read(cx).focus_handle(cx);
284 let Some(trigger) = self.trigger else {
285 return div().id("empty").into_any_element();
286 };
287 let parent_view_id = window.current_view();
288 let popup = Popup::new(self.id, trigger(open, window, cx))
289 .anchor(self.anchor)
290 .key_context(CONTEXT)
291 .on_action({
292 let state = state.clone();
293 move |_: &Confirm, window, cx| {
294 state.update(cx, |state, cx| state.toggle_open(window, cx));
295 cx.notify(parent_view_id);
296 }
297 })
298 .on_mouse_down(self.mouse_button, {
299 let state = state.clone();
300 move |_, window, cx| {
301 cx.stop_propagation();
302 state.update(cx, |state, cx| {
303 if state.is_open() == open {
304 state.toggle_open(window, cx);
305 }
306 });
307 cx.notify(parent_view_id);
308 }
309 });
310 if !open {
311 return popup.into_any_element();
312 }
313
314 let content = div()
315 .id("content")
316 .role(Role::Dialog)
320 .occlude()
321 .tab_group()
322 .track_focus(&focus_handle)
323 .key_context(CONTEXT)
324 .on_action(window.listener_for(&state, PopoverState::on_action_cancel))
325 .when_some(self.content, |this, content| {
326 this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
327 })
328 .when(self.overlay_closable, |this| {
329 this.on_mouse_down_out({
330 let state = state.clone();
331 move |_, window, cx| {
332 state.update(cx, |state, cx| state.dismiss(window, cx));
333 cx.notify(parent_view_id);
334 }
335 })
336 });
337 popup.content(content).into_any_element()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use gpui::{AppContext as _, Context, Render, Styled as _, point, px};
345 use std::{cell::RefCell, rc::Rc};
346
347 #[gpui::test]
352 fn a_state_dropped_while_open_closes_the_deferred_context(cx: &mut gpui::TestAppContext) {
353 let state = cx.update(|cx| {
354 GlobalState::init(cx);
355 let state = cx.new(|cx| PopoverState::new(false, cx));
356 state.update(cx, |state, cx| state.set_open(true, cx));
357 assert!(GlobalState::is_in_deferred_context(cx));
358 state
359 });
360
361 cx.update(|_| drop(state));
362 cx.update(|cx| assert!(!GlobalState::is_in_deferred_context(cx)));
363 }
364
365 #[gpui::test]
366 fn open_state_registers_and_unregisters_deferred_context(cx: &mut gpui::TestAppContext) {
367 cx.update(|cx| {
368 GlobalState::init(cx);
369 let state = cx.new(|cx| PopoverState::new(false, cx));
370
371 state.update(cx, |state, cx| state.set_open(true, cx));
372 assert!(state.read(cx).is_open());
373 assert!(GlobalState::is_in_deferred_context(cx));
374
375 state.update(cx, |state, cx| state.set_open(false, cx));
376 assert!(!state.read(cx).is_open());
377 assert!(!GlobalState::is_in_deferred_context(cx));
378 });
379 }
380
381 struct PopoverHarness {
382 changes: Rc<RefCell<Vec<bool>>>,
383 default_open: bool,
384 }
385
386 struct KeyboardPopoverHarness {
387 trigger_focus: FocusHandle,
388 }
389
390 impl Render for KeyboardPopoverHarness {
391 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
392 Popover::new("keyboard-popover")
393 .trigger(
394 crate::Button::new("keyboard-trigger")
395 .track_focus(&self.trigger_focus)
396 .child("Open"),
397 )
398 .content(|_, _, _| {
399 div()
400 .debug_selector(|| "keyboard-popover-content".into())
401 .size(px(40.))
402 })
403 }
404 }
405
406 impl Render for PopoverHarness {
407 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
408 let changes = self.changes.clone();
409 Popover::new("base-popover")
410 .default_open(self.default_open)
411 .trigger_with(|_, _, _| div().child("Open").into_any_element())
412 .content(|_, _, _| {
413 div()
414 .debug_selector(|| "base-popover-content".into())
415 .size(px(40.))
416 })
417 .on_open_change(move |open, _, _| changes.borrow_mut().push(*open))
418 }
419 }
420
421 #[gpui::test]
422 fn unstyled_popover_owns_pointer_open_and_outside_dismiss(cx: &mut gpui::TestAppContext) {
423 cx.update(crate::init);
424 let changes = Rc::new(RefCell::new(Vec::new()));
425 let (_, cx) = cx.add_window_view({
426 let changes = changes.clone();
427 move |_, _| PopoverHarness {
428 changes,
429 default_open: false,
430 }
431 });
432 cx.update(|window, cx| window.draw(cx).clear(cx));
433
434 cx.simulate_click(point(px(20.), px(10.)), Default::default());
435 cx.update(|window, cx| window.draw(cx).clear(cx));
436 assert!(cx.debug_bounds("base-popover-content").is_some());
437
438 cx.simulate_click(point(px(300.), px(300.)), Default::default());
439 cx.update(|window, cx| window.draw(cx).clear(cx));
440 assert!(cx.debug_bounds("base-popover-content").is_none());
441 assert_eq!(&*changes.borrow(), &[true, false]);
442 }
443
444 #[gpui::test]
445 fn default_open_renders_content_without_activation(cx: &mut gpui::TestAppContext) {
446 cx.update(crate::init);
447 let (_, cx) = cx.add_window_view(|_, _| PopoverHarness {
448 changes: Rc::new(RefCell::new(Vec::new())),
449 default_open: true,
450 });
451 cx.update(|window, cx| window.draw(cx).clear(cx));
452 cx.update(|window, cx| window.draw(cx).clear(cx));
453 assert!(cx.debug_bounds("base-popover-content").is_some());
454 }
455
456 #[gpui::test]
457 fn keyboard_activation_opens_the_popover(cx: &mut gpui::TestAppContext) {
458 cx.update(crate::init);
459 let (view, cx) = cx.add_window_view(|_, cx| KeyboardPopoverHarness {
460 trigger_focus: cx.focus_handle(),
461 });
462 cx.update(|window, cx| window.draw(cx).clear(cx));
463
464 cx.update(|window, cx| {
465 let focus = view.read(cx).trigger_focus.clone();
466 focus.focus(window, cx);
467 });
468 cx.simulate_keystrokes("enter");
469 cx.update(|window, cx| window.draw(cx).clear(cx));
470
471 assert!(cx.debug_bounds("keyboard-popover-content").is_some());
472 }
473}