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