1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4 AnyElement, AnyView, App, Bounds, Context, Div, ElementId, InteractiveElement, IntoElement,
5 ParentElement, Pixels, Render, RenderOnce, Role, Stateful, StatefulInteractiveElement, Styled,
6 Task, Window, deferred, div, prelude::FluentBuilder as _, px,
7};
8
9use crate::{Placement, Positioner};
10
11const TOOLTIP_PRIORITY: usize = 200;
12const WINDOW_MARGIN: Pixels = px(4.);
13const GRACE_PERIOD: Duration = Duration::from_millis(300);
14const SHOW_DELAY: Duration = Duration::from_millis(500);
15
16type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
17type TooltipRenderer = Rc<dyn Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement>;
18
19#[derive(IntoElement)]
24pub struct Tooltip {
25 base: Stateful<Div>,
26}
27
28impl Tooltip {
29 pub fn new(id: impl Into<ElementId>) -> Self {
30 Self {
31 base: div().id(id).role(Role::Tooltip),
32 }
33 }
34}
35
36impl Styled for Tooltip {
37 fn style(&mut self) -> &mut gpui::StyleRefinement {
38 self.base.style()
39 }
40}
41
42impl ParentElement for Tooltip {
43 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
44 self.base.extend(children);
45 }
46}
47
48impl RenderOnce for Tooltip {
49 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
50 self.base
51 }
52}
53
54#[derive(Clone)]
56pub struct TooltipRequest {
57 build: TooltipBuilder,
58 trigger_bounds: Bounds<Pixels>,
59 preferred_placement: Option<Placement>,
60}
61
62impl TooltipRequest {
63 pub fn new(
64 trigger_bounds: Bounds<Pixels>,
65 build: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
66 ) -> Self {
67 Self {
68 build: Rc::new(build),
69 trigger_bounds,
70 preferred_placement: None,
71 }
72 }
73
74 pub fn placement(mut self, placement: Placement) -> Self {
75 self.preferred_placement = Some(placement);
76 self
77 }
78}
79
80#[derive(Clone, Copy, Debug)]
82pub enum TooltipTransition {
83 Enter {
84 epoch: usize,
85 },
86 Switch {
87 epoch: usize,
88 previous: Bounds<Pixels>,
89 current: Bounds<Pixels>,
90 },
91}
92
93pub struct TooltipOverlay {
98 enabled: bool,
99 content: Option<TooltipRequest>,
100 previous_bounds: Option<Bounds<Pixels>>,
101 epoch: usize,
102 had_recent_tooltip: bool,
103 animation_epoch: usize,
104 is_switching: bool,
105 show_task: Option<Task<()>>,
106 hide_task: Option<Task<()>>,
107 renderer: TooltipRenderer,
108}
109
110impl TooltipOverlay {
111 pub fn new() -> Self {
112 Self {
113 enabled: !crate::is_mobile(),
114 content: None,
115 previous_bounds: None,
116 epoch: 0,
117 had_recent_tooltip: false,
118 animation_epoch: 0,
119 is_switching: false,
120 show_task: None,
121 hide_task: None,
122 renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
123 }
124 }
125
126 pub fn render_with(
127 mut self,
128 renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
129 ) -> Self {
130 self.renderer = Rc::new(renderer);
131 self
132 }
133
134 fn next_epoch(&mut self) -> usize {
135 self.epoch += 1;
136 self.epoch
137 }
138
139 pub fn request_show(
140 &mut self,
141 content: TooltipRequest,
142 window: &mut Window,
143 cx: &mut Context<Self>,
144 ) {
145 if !self.enabled {
148 return;
149 }
150 self.hide_task = None;
151 let was_visible = self.content.is_some();
152 if was_visible || self.had_recent_tooltip {
153 self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
154 self.content = Some(content);
155 self.show_task = None;
156 self.is_switching = was_visible;
157 self.animation_epoch += 1;
158 cx.notify();
159 return;
160 }
161
162 let epoch = self.next_epoch();
163 self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
164 cx.background_executor().timer(SHOW_DELAY).await;
165 let _ = this.update_in(cx, |this, _, cx| {
166 if this.epoch == epoch {
167 this.content = Some(content);
168 this.previous_bounds = None;
169 this.is_switching = false;
170 this.animation_epoch += 1;
171 cx.notify();
172 }
173 });
174 }));
175 }
176
177 pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
178 self.show_task = None;
179 if self.content.is_none() {
180 return;
181 }
182 let epoch = self.next_epoch();
183 self.had_recent_tooltip = true;
184 self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
185 cx.background_executor().timer(GRACE_PERIOD).await;
186 let _ = this.update_in(cx, |this, _, cx| {
187 if this.epoch == epoch {
188 this.content = None;
189 this.previous_bounds = None;
190 this.had_recent_tooltip = false;
191 cx.notify();
192 }
193 });
194 }));
195 }
196
197 pub fn hide(&mut self, cx: &mut Context<Self>) {
198 let changed = self.content.is_some()
199 || self.previous_bounds.is_some()
200 || self.had_recent_tooltip
201 || self.show_task.is_some()
202 || self.hide_task.is_some();
203 self.content = None;
204 self.previous_bounds = None;
205 self.had_recent_tooltip = false;
206 self.is_switching = false;
207 self.show_task = None;
208 self.hide_task = None;
209 if changed {
210 cx.notify();
211 }
212 }
213}
214
215impl Default for TooltipOverlay {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221impl Render for TooltipOverlay {
222 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223 let Some(content) = self.content.as_ref() else {
224 return div().into_any_element();
225 };
226 let view = (content.build)(window, cx);
227 let transition = match (self.is_switching, self.previous_bounds) {
228 (true, Some(previous)) => TooltipTransition::Switch {
229 epoch: self.animation_epoch,
230 previous,
231 current: content.trigger_bounds,
232 },
233 _ => TooltipTransition::Enter {
234 epoch: self.animation_epoch,
235 },
236 };
237 let rendered = (self.renderer)(view, transition, window, cx);
238 deferred(
239 TooltipPositioner::new(content.trigger_bounds)
240 .when_some(content.preferred_placement, |this, placement| {
241 this.placement(placement)
242 })
243 .child(rendered),
244 )
245 .with_priority(TOOLTIP_PRIORITY)
246 .into_any_element()
247 }
248}
249
250pub struct TooltipPositioner(Positioner);
255
256impl TooltipPositioner {
257 pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
258 Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
259 }
260
261 pub fn placement(mut self, placement: Placement) -> Self {
262 self.0 = self.0.placement(placement);
263 self
264 }
265}
266
267impl ParentElement for TooltipPositioner {
268 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
269 self.0.extend(elements);
270 }
271}
272
273impl IntoElement for TooltipPositioner {
274 type Element = Positioner;
275
276 fn into_element(self) -> Self::Element {
277 self.0
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use gpui::{AppContext as _, point, size};
285
286 fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
287 Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
288 }
289
290 #[gpui::test]
291 fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
292 let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
293 let cx = cx.add_empty_window();
294 cx.update(|window, cx| {
295 state.update(cx, |tooltip, cx| {
296 tooltip.had_recent_tooltip = true;
297 tooltip.request_show(
298 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
299 panic!("content is not rendered by this lifecycle test")
300 }),
301 window,
302 cx,
303 );
304 });
305 });
306 cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
307
308 cx.update(|_, cx| {
309 state.update(cx, |tooltip, cx| tooltip.hide(cx));
310 });
311 cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
312 }
313
314 #[test]
315 fn tooltip_priority_exceeds_popup_layer() {
316 assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
317 }
318
319 #[gpui::test]
320 fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) {
321 let state = cx.update(|cx| {
322 cx.new(|_| TooltipOverlay {
323 enabled: false,
324 ..TooltipOverlay::new()
325 })
326 });
327 let cx = cx.add_empty_window();
328 for had_recent_tooltip in [false, true] {
329 cx.update(|window, cx| {
330 state.update(cx, |tooltip, cx| {
331 tooltip.had_recent_tooltip = had_recent_tooltip;
332 tooltip.request_show(
333 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
334 panic!("disabled tooltips must not build content")
335 }),
336 window,
337 cx,
338 );
339 assert!(tooltip.content.is_none());
340 assert!(tooltip.show_task.is_none());
341 assert!(tooltip.hide_task.is_none());
342 assert_eq!(tooltip.animation_epoch, 0);
343 });
344 });
345 }
346 }
347}