1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4 AnyElement, AnyView, App, Bounds, Context, Div, ElementId, Global, InteractiveElement,
5 IntoElement, ParentElement, Pixels, Render, RenderOnce, Role, Stateful,
6 StatefulInteractiveElement, Styled, Task, Window, deferred, div, prelude::FluentBuilder as _,
7 px,
8};
9
10use crate::{Placement, Positioner};
11
12const TOOLTIP_PRIORITY: usize = 200;
13const WINDOW_MARGIN: Pixels = px(4.);
14
15type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
16type TooltipRenderer = Rc<dyn Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement>;
17
18#[derive(IntoElement)]
23pub struct Tooltip {
24 base: Stateful<Div>,
25}
26
27impl Tooltip {
28 pub fn new(id: impl Into<ElementId>) -> Self {
29 Self {
30 base: div().id(id).role(Role::Tooltip),
31 }
32 }
33}
34
35impl Styled for Tooltip {
36 fn style(&mut self) -> &mut gpui::StyleRefinement {
37 self.base.style()
38 }
39}
40
41impl ParentElement for Tooltip {
42 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
43 self.base.extend(children);
44 }
45}
46
47impl RenderOnce for Tooltip {
48 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
49 self.base
50 }
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct TooltipDefaults {
59 show_delay: Duration,
60 grace_period: Duration,
61}
62
63impl Global for TooltipDefaults {}
64
65impl TooltipDefaults {
66 pub fn new() -> Self {
68 Self {
69 show_delay: Duration::from_millis(500),
70 grace_period: Duration::from_millis(300),
71 }
72 }
73
74 pub fn with_show_delay(mut self, delay: Duration) -> Self {
76 self.show_delay = delay;
77 self
78 }
79
80 pub fn with_grace_period(mut self, period: Duration) -> Self {
85 self.grace_period = period;
86 self
87 }
88
89 pub fn show_delay(&self) -> Duration {
91 self.show_delay
92 }
93
94 pub fn grace_period(&self) -> Duration {
96 self.grace_period
97 }
98
99 pub fn install(self, cx: &mut App) {
101 cx.set_global(self);
102 }
103
104 pub fn global(cx: &App) -> Self {
106 cx.try_global::<Self>().copied().unwrap_or_default()
107 }
108}
109
110impl Default for TooltipDefaults {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116#[derive(Clone)]
118pub struct TooltipRequest {
119 build: TooltipBuilder,
120 trigger_bounds: Bounds<Pixels>,
121 preferred_placement: Option<Placement>,
122 show_delay: Option<Duration>,
123}
124
125impl TooltipRequest {
126 pub fn new(
127 trigger_bounds: Bounds<Pixels>,
128 build: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
129 ) -> Self {
130 Self {
131 build: Rc::new(build),
132 trigger_bounds,
133 preferred_placement: None,
134 show_delay: None,
135 }
136 }
137
138 pub fn with_placement(mut self, placement: Placement) -> Self {
140 self.preferred_placement = Some(placement);
141 self
142 }
143
144 #[deprecated(note = "use `with_placement`")]
145 pub fn placement(self, placement: Placement) -> Self {
146 self.with_placement(placement)
147 }
148
149 pub fn with_show_delay(mut self, delay: Duration) -> Self {
151 self.show_delay = Some(delay);
152 self
153 }
154}
155
156#[derive(Clone, Copy, Debug)]
158pub enum TooltipTransition {
159 Enter {
160 epoch: usize,
161 },
162 Switch {
163 epoch: usize,
164 previous: Bounds<Pixels>,
165 current: Bounds<Pixels>,
166 },
167}
168
169pub struct TooltipOverlay {
174 enabled: bool,
175 content: Option<TooltipRequest>,
176 previous_bounds: Option<Bounds<Pixels>>,
177 epoch: usize,
178 had_recent_tooltip: bool,
179 animation_epoch: usize,
180 is_switching: bool,
181 show_task: Option<Task<()>>,
182 hide_task: Option<Task<()>>,
183 renderer: TooltipRenderer,
184}
185
186impl TooltipOverlay {
187 pub fn new() -> Self {
188 Self {
189 enabled: !crate::is_mobile(),
190 content: None,
191 previous_bounds: None,
192 epoch: 0,
193 had_recent_tooltip: false,
194 animation_epoch: 0,
195 is_switching: false,
196 show_task: None,
197 hide_task: None,
198 renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
199 }
200 }
201
202 pub fn render_with(
203 mut self,
204 renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
205 ) -> Self {
206 self.renderer = Rc::new(renderer);
207 self
208 }
209
210 fn next_epoch(&mut self) -> usize {
211 self.epoch += 1;
212 self.epoch
213 }
214
215 pub fn request_show(
216 &mut self,
217 content: TooltipRequest,
218 window: &mut Window,
219 cx: &mut Context<Self>,
220 ) {
221 if !self.enabled {
224 return;
225 }
226 self.hide_task = None;
227 let show_delay = content
228 .show_delay
229 .unwrap_or_else(|| TooltipDefaults::global(cx).show_delay);
230 let was_visible = self.content.is_some();
231 if was_visible || self.had_recent_tooltip || show_delay.is_zero() {
232 self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
233 self.content = Some(content);
234 self.show_task = None;
235 self.is_switching = was_visible;
236 self.animation_epoch += 1;
237 cx.notify();
238 return;
239 }
240
241 let epoch = self.next_epoch();
242 self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
243 cx.background_executor().timer(show_delay).await;
244 let _ = this.update_in(cx, |this, _, cx| {
245 if this.epoch == epoch {
246 this.content = Some(content);
247 this.previous_bounds = None;
248 this.is_switching = false;
249 this.animation_epoch += 1;
250 cx.notify();
251 }
252 });
253 }));
254 }
255
256 pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
257 self.show_task = None;
258 if self.content.is_none() {
259 return;
260 }
261 let epoch = self.next_epoch();
262 let grace_period = TooltipDefaults::global(cx).grace_period;
263 self.had_recent_tooltip = true;
264 self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
265 cx.background_executor().timer(grace_period).await;
266 let _ = this.update_in(cx, |this, _, cx| {
267 if this.epoch == epoch {
268 this.content = None;
269 this.previous_bounds = None;
270 this.had_recent_tooltip = false;
271 cx.notify();
272 }
273 });
274 }));
275 }
276
277 pub fn hide(&mut self, cx: &mut Context<Self>) {
278 let changed = self.content.is_some()
279 || self.previous_bounds.is_some()
280 || self.had_recent_tooltip
281 || self.show_task.is_some()
282 || self.hide_task.is_some();
283 self.content = None;
284 self.previous_bounds = None;
285 self.had_recent_tooltip = false;
286 self.is_switching = false;
287 self.show_task = None;
288 self.hide_task = None;
289 if changed {
290 cx.notify();
291 }
292 }
293}
294
295impl Default for TooltipOverlay {
296 fn default() -> Self {
297 Self::new()
298 }
299}
300
301impl Render for TooltipOverlay {
302 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
303 let Some(content) = self.content.as_ref() else {
304 return div().into_any_element();
305 };
306 let view = (content.build)(window, cx);
307 let transition = match (self.is_switching, self.previous_bounds) {
308 (true, Some(previous)) => TooltipTransition::Switch {
309 epoch: self.animation_epoch,
310 previous,
311 current: content.trigger_bounds,
312 },
313 _ => TooltipTransition::Enter {
314 epoch: self.animation_epoch,
315 },
316 };
317 let rendered = (self.renderer)(view, transition, window, cx);
318 deferred(
319 TooltipPositioner::new(content.trigger_bounds)
320 .when_some(content.preferred_placement, |this, placement| {
321 this.placement(placement)
322 })
323 .child(rendered),
324 )
325 .with_priority(TOOLTIP_PRIORITY)
326 .into_any_element()
327 }
328}
329
330pub struct TooltipPositioner(Positioner);
335
336impl TooltipPositioner {
337 pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
338 Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
339 }
340
341 pub fn placement(mut self, placement: Placement) -> Self {
342 self.0 = self.0.placement(placement);
343 self
344 }
345}
346
347impl ParentElement for TooltipPositioner {
348 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
349 self.0.extend(elements);
350 }
351}
352
353impl IntoElement for TooltipPositioner {
354 type Element = Positioner;
355
356 fn into_element(self) -> Self::Element {
357 self.0
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use gpui::{AppContext as _, point, size};
365
366 fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
367 Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
368 }
369
370 #[gpui::test]
371 fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
372 let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
373 let cx = cx.add_empty_window();
374 cx.update(|window, cx| {
375 state.update(cx, |tooltip, cx| {
376 tooltip.had_recent_tooltip = true;
377 tooltip.request_show(
378 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
379 panic!("content is not rendered by this lifecycle test")
380 }),
381 window,
382 cx,
383 );
384 });
385 });
386 cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
387
388 cx.update(|_, cx| {
389 state.update(cx, |tooltip, cx| tooltip.hide(cx));
390 });
391 cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
392 }
393
394 #[gpui::test]
395 fn show_delay_follows_defaults_and_request_override(cx: &mut gpui::TestAppContext) {
396 cx.update(|cx| {
397 TooltipDefaults::new()
398 .with_show_delay(Duration::from_millis(100))
399 .install(cx)
400 });
401 let (state, cx) = cx.add_window_view(|_, _| TooltipOverlay::new());
403 cx.update(|window, cx| window.draw(cx).clear(cx));
404 let request = || {
405 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, cx| {
406 cx.new(|_| gpui::Empty).into()
407 })
408 };
409
410 cx.update(|window, cx| {
411 state.update(cx, |tooltip, cx| {
412 tooltip.request_show(request(), window, cx)
413 });
414 });
415 cx.executor().advance_clock(Duration::from_millis(99));
416 cx.run_until_parked();
417 cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
418 cx.executor().advance_clock(Duration::from_millis(1));
419 cx.run_until_parked();
420 cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
421
422 cx.update(|_, cx| state.update(cx, |tooltip, cx| tooltip.hide(cx)));
423 cx.update(|window, cx| {
424 state.update(cx, |tooltip, cx| {
425 tooltip.request_show(request().with_show_delay(Duration::ZERO), window, cx);
426 assert!(tooltip.content.is_some());
427 assert!(tooltip.show_task.is_none());
428 });
429 });
430 }
431
432 #[test]
433 fn tooltip_priority_exceeds_popup_layer() {
434 assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
435 }
436
437 #[gpui::test]
438 fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) {
439 let state = cx.update(|cx| {
440 cx.new(|_| TooltipOverlay {
441 enabled: false,
442 ..TooltipOverlay::new()
443 })
444 });
445 let cx = cx.add_empty_window();
446 for had_recent_tooltip in [false, true] {
447 cx.update(|window, cx| {
448 state.update(cx, |tooltip, cx| {
449 tooltip.had_recent_tooltip = had_recent_tooltip;
450 tooltip.request_show(
451 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
452 panic!("disabled tooltips must not build content")
453 }),
454 window,
455 cx,
456 );
457 assert!(tooltip.content.is_none());
458 assert!(tooltip.show_task.is_none());
459 assert!(tooltip.hide_task.is_none());
460 assert_eq!(tooltip.animation_epoch, 0);
461 });
462 });
463 }
464 }
465}