1use std::rc::Rc;
2
3use gpui::{
4 Anchor, AnyElement, App, Context, ElementId, InteractiveElement as _, IntoElement,
5 ParentElement as _, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
6 StyleRefinement, Styled, Task, Window, div, prelude::FluentBuilder as _,
7};
8use instant::Duration;
9
10use crate::{Popup, StyledExt as _};
11
12type ContentBuilder = Box<
13 dyn FnOnce(
14 &mut HoverCardState,
15 &mut Window,
16 &mut Context<HoverCardState>,
17 ) -> Stateful<gpui::Div>,
18>;
19type OpenChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
20
21#[derive(IntoElement)]
24pub struct HoverCard {
25 id: ElementId,
26 style: StyleRefinement,
27 anchor: Anchor,
28 tap_to_open: bool,
29 trigger: Option<AnyElement>,
30 content: Option<ContentBuilder>,
31 open_delay: Duration,
32 close_delay: Duration,
33 on_open_change: Option<OpenChangeHandler>,
34}
35
36impl HoverCard {
37 pub fn new(id: impl Into<ElementId>) -> Self {
38 Self {
39 id: id.into(),
40 style: StyleRefinement::default(),
41 anchor: Anchor::TopCenter,
42 tap_to_open: crate::is_mobile(),
43 trigger: None,
44 content: None,
45 open_delay: Duration::from_secs_f64(0.6),
46 close_delay: Duration::from_secs_f64(0.3),
47 on_open_change: None,
48 }
49 }
50
51 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
52 self.anchor = anchor.into();
53 self
54 }
55
56 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
57 self.trigger = Some(trigger.into_any_element());
58 self
59 }
60
61 pub fn content<F>(mut self, content: F) -> Self
62 where
63 F: FnOnce(
64 &mut HoverCardState,
65 &mut Window,
66 &mut Context<HoverCardState>,
67 ) -> Stateful<gpui::Div>
68 + 'static,
69 {
70 self.content = Some(Box::new(content));
71 self
72 }
73
74 pub fn open_delay(mut self, duration: Duration) -> Self {
75 self.open_delay = duration;
76 self
77 }
78
79 pub fn close_delay(mut self, duration: Duration) -> Self {
80 self.close_delay = duration;
81 self
82 }
83
84 pub fn on_open_change(
85 mut self,
86 callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
87 ) -> Self {
88 self.on_open_change = Some(Rc::new(callback));
89 self
90 }
91}
92
93impl Styled for HoverCard {
96 fn style(&mut self) -> &mut StyleRefinement {
97 &mut self.style
98 }
99}
100
101pub struct HoverCardState {
103 open: bool,
104 open_delay: Duration,
105 close_delay: Duration,
106 on_open_change: Option<OpenChangeHandler>,
107 open_task: Option<Task<()>>,
108 close_task: Option<Task<()>>,
109 epoch: usize,
110 is_hovering_trigger: bool,
111 is_hovering_content: bool,
112}
113
114impl HoverCardState {
115 fn new(open_delay: Duration, close_delay: Duration) -> Self {
116 Self {
117 open: false,
118 open_delay,
119 close_delay,
120 on_open_change: None,
121 open_task: None,
122 close_task: None,
123 epoch: 0,
124 is_hovering_trigger: false,
125 is_hovering_content: false,
126 }
127 }
128
129 pub fn is_open(&self) -> bool {
130 self.open
131 }
132
133 fn sync(
134 &mut self,
135 open_delay: Duration,
136 close_delay: Duration,
137 on_open_change: Option<OpenChangeHandler>,
138 ) {
139 self.open_delay = open_delay;
140 self.close_delay = close_delay;
141 self.on_open_change = on_open_change;
142 }
143
144 fn schedule_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
145 self.cancel_tasks();
146 let epoch = self.next_epoch();
147 let delay = self.open_delay;
148 self.open_task = Some(cx.spawn_in(window, async move |this, cx| {
149 cx.background_executor().timer(delay).await;
150 let _ = this.update_in(cx, |state, window, cx| {
151 if state.epoch == epoch {
152 state.set_open(true, window, cx);
153 }
154 });
155 }));
156 }
157
158 fn schedule_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
159 self.cancel_tasks();
160 let epoch = self.next_epoch();
161 let delay = self.close_delay;
162 self.close_task = Some(cx.spawn_in(window, async move |this, cx| {
163 cx.background_executor().timer(delay).await;
164 let _ = this.update_in(cx, |state, window, cx| {
165 if state.epoch == epoch && !state.is_hovering_trigger && !state.is_hovering_content
166 {
167 state.set_open(false, window, cx);
168 }
169 });
170 }));
171 }
172
173 fn cancel_tasks(&mut self) {
174 self.epoch += 1;
175 self.open_task = None;
176 self.close_task = None;
177 }
178
179 fn next_epoch(&mut self) -> usize {
180 self.epoch += 1;
181 self.epoch
182 }
183
184 fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
185 if self.open == open {
186 return;
187 }
188
189 self.open = open;
190 cx.notify();
191 if let Some(on_open_change) = self.on_open_change.clone() {
195 on_open_change(&open, window, cx);
196 }
197 }
198
199 fn on_trigger_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
200 self.is_hovering_trigger = hovering;
201 if hovering {
202 self.schedule_open(window, cx);
203 } else if !self.is_hovering_content {
204 self.schedule_close(window, cx);
205 }
206 }
207
208 fn on_content_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
209 self.is_hovering_content = hovering;
210 if hovering {
211 self.cancel_tasks();
212 } else if !self.is_hovering_trigger {
213 self.schedule_close(window, cx);
214 }
215 }
216}
217
218impl Render for HoverCardState {
219 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
220 div()
221 }
222}
223
224impl RenderOnce for HoverCard {
225 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
226 let state = window.use_keyed_state(self.id.clone(), cx, |_, _| {
227 HoverCardState::new(self.open_delay, self.close_delay)
228 });
229 state.update(cx, |state, _| {
230 state.sync(self.open_delay, self.close_delay, self.on_open_change)
231 });
232 let open = state.read(cx).is_open();
233
234 let trigger = self.trigger.unwrap_or_else(|| div().into_any_element());
235 let popup = Popup::new(
236 self.id,
237 div()
238 .id("trigger")
239 .child(trigger)
240 .when(self.tap_to_open, |trigger| {
241 trigger.on_click(window.listener_for(&state, move |state, _, window, cx| {
242 state.cancel_tasks();
243 state.set_open(!open, window, cx);
246 }))
247 })
248 .when(!self.tap_to_open, |trigger| {
249 trigger.on_hover(window.listener_for(&state, |state, hovered, window, cx| {
250 state.on_trigger_hover(*hovered, window, cx)
251 }))
252 }),
253 )
254 .refine_style(&self.style)
255 .anchor(self.anchor);
256
257 if !open {
258 return popup;
259 }
260
261 popup.when_some(self.content, |popup, content| {
262 let hover = window.listener_for(&state, |state, hovered, window, cx| {
263 state.on_content_hover(*hovered, window, cx)
264 });
265 let dismiss = window.listener_for(&state, |state, _, window, cx| {
266 state.cancel_tasks();
267 state.set_open(false, window, cx);
268 });
269 popup.content(state.update(cx, |state, cx| {
270 content(state, window, cx)
271 .when(self.tap_to_open, |content| {
272 content.on_mouse_up_out(gpui::MouseButton::Left, dismiss)
273 })
274 .when(!self.tap_to_open, |content| content.on_hover(hover))
275 }))
276 })
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use std::cell::RefCell;
283
284 use gpui::{Context, Render, TestAppContext, point, px};
285
286 use super::*;
287
288 #[derive(Default)]
289 struct Harness {
290 open_changes: Rc<RefCell<Vec<bool>>>,
291 tap_to_open: bool,
292 }
293
294 impl Render for Harness {
295 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
296 let delay = Duration::from_millis(100);
297 let open_changes = self.open_changes.clone();
298 HoverCard::new("hover-card")
299 .map(|mut card| {
300 card.tap_to_open = self.tap_to_open;
301 card
302 })
303 .open_delay(delay)
304 .close_delay(delay)
305 .on_open_change(move |open, _, _| open_changes.borrow_mut().push(*open))
306 .trigger(
307 div()
308 .debug_selector(|| "hover-card-trigger".into())
309 .size(px(20.)),
310 )
311 .content(|_, _, _| {
312 div()
313 .id("hover-card-content")
314 .debug_selector(|| "hover-card-content".into())
315 .size(px(10.))
316 })
317 }
318 }
319
320 #[gpui::test]
321 fn public_hover_card_owns_delayed_open_and_close(cx: &mut TestAppContext) {
322 let delay = Duration::from_millis(100);
323 let (_, cx) = cx.add_window_view(|_, _| Harness::default());
324 cx.update(|window, cx| window.draw(cx).clear(cx));
325
326 cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
327 cx.executor().advance_clock(delay);
328 cx.run_until_parked();
329 cx.update(|window, cx| {
330 window.draw(cx).clear(cx);
331 window.draw(cx).clear(cx);
332 });
333 assert!(cx.debug_bounds("hover-card-content").is_some());
334
335 cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
336 cx.executor().advance_clock(delay);
337 cx.run_until_parked();
338 cx.update(|window, cx| window.draw(cx).clear(cx));
339 assert!(cx.debug_bounds("hover-card-content").is_none());
340 }
341
342 #[gpui::test]
343 fn public_hover_card_reports_each_open_change(cx: &mut TestAppContext) {
344 let delay = Duration::from_millis(100);
345 let open_changes = Rc::new(RefCell::new(Vec::new()));
346 let (_, cx) = cx.add_window_view({
347 let open_changes = open_changes.clone();
348 move |_, _| Harness {
349 open_changes,
350 ..Default::default()
351 }
352 });
353 cx.update(|window, cx| window.draw(cx).clear(cx));
354
355 cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
356 assert_eq!(*open_changes.borrow(), Vec::<bool>::new());
357
358 cx.executor().advance_clock(delay);
359 cx.run_until_parked();
360 cx.update(|window, cx| {
361 window.draw(cx).clear(cx);
362 window.draw(cx).clear(cx);
363 });
364 assert_eq!(*open_changes.borrow(), vec![true]);
365
366 cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
367 cx.executor().advance_clock(delay);
368 cx.run_until_parked();
369 cx.update(|window, cx| window.draw(cx).clear(cx));
370 assert_eq!(*open_changes.borrow(), vec![true, false]);
371 }
372 #[gpui::test]
373 fn tap_card_ignores_hover_and_toggles_and_dismisses(cx: &mut TestAppContext) {
374 let changes = Rc::new(RefCell::new(Vec::new()));
375 let (_, cx) = cx.add_window_view({
376 let changes = changes.clone();
377 move |_, _| Harness {
378 tap_to_open: true,
379 open_changes: changes,
380 }
381 });
382 cx.update(|window, cx| window.draw(cx).clear(cx));
383 cx.simulate_mouse_move(point(px(10.), px(10.)), None, Default::default());
384 cx.executor().advance_clock(Duration::from_secs(1));
385 cx.run_until_parked();
386 assert!(changes.borrow().is_empty());
387
388 cx.simulate_click(point(px(10.), px(10.)), Default::default());
389 cx.update(|window, cx| {
390 window.draw(cx).clear(cx);
391 window.draw(cx).clear(cx);
392 });
393 assert!(cx.debug_bounds("hover-card-content").is_some());
394 cx.simulate_mouse_move(point(px(100.), px(100.)), None, Default::default());
395 cx.executor().advance_clock(Duration::from_secs(1));
396 cx.run_until_parked();
397 assert_eq!(*changes.borrow(), vec![true]);
398
399 cx.simulate_click(point(px(10.), px(10.)), Default::default());
400 cx.update(|window, cx| window.draw(cx).clear(cx));
401 assert!(cx.debug_bounds("hover-card-content").is_none());
402 assert_eq!(*changes.borrow(), vec![true, false]);
403
404 cx.simulate_click(point(px(10.), px(10.)), Default::default());
405 cx.update(|window, cx| {
406 window.draw(cx).clear(cx);
407 window.draw(cx).clear(cx);
408 });
409 let bounds = cx.debug_bounds("hover-card-content").unwrap();
410 cx.simulate_click(bounds.center(), Default::default());
411 assert_eq!(*changes.borrow(), vec![true, false, true]);
412 cx.simulate_click(point(px(100.), px(100.)), Default::default());
413 cx.update(|window, cx| window.draw(cx).clear(cx));
414 assert!(cx.debug_bounds("hover-card-content").is_none());
415 assert_eq!(*changes.borrow(), vec![true, false, true, false]);
416 }
417}