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 {
95 content: Option<TooltipRequest>,
96 previous_bounds: Option<Bounds<Pixels>>,
97 epoch: usize,
98 had_recent_tooltip: bool,
99 animation_epoch: usize,
100 is_switching: bool,
101 show_task: Option<Task<()>>,
102 hide_task: Option<Task<()>>,
103 renderer: TooltipRenderer,
104}
105
106impl TooltipOverlay {
107 pub fn new() -> Self {
108 Self {
109 content: None,
110 previous_bounds: None,
111 epoch: 0,
112 had_recent_tooltip: false,
113 animation_epoch: 0,
114 is_switching: false,
115 show_task: None,
116 hide_task: None,
117 renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
118 }
119 }
120
121 pub fn render_with(
122 mut self,
123 renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
124 ) -> Self {
125 self.renderer = Rc::new(renderer);
126 self
127 }
128
129 fn next_epoch(&mut self) -> usize {
130 self.epoch += 1;
131 self.epoch
132 }
133
134 pub fn request_show(
135 &mut self,
136 content: TooltipRequest,
137 window: &mut Window,
138 cx: &mut Context<Self>,
139 ) {
140 self.hide_task = None;
141 let was_visible = self.content.is_some();
142 if was_visible || self.had_recent_tooltip {
143 self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
144 self.content = Some(content);
145 self.show_task = None;
146 self.is_switching = was_visible;
147 self.animation_epoch += 1;
148 cx.notify();
149 return;
150 }
151
152 let epoch = self.next_epoch();
153 self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
154 cx.background_executor().timer(SHOW_DELAY).await;
155 let _ = this.update_in(cx, |this, _, cx| {
156 if this.epoch == epoch {
157 this.content = Some(content);
158 this.previous_bounds = None;
159 this.is_switching = false;
160 this.animation_epoch += 1;
161 cx.notify();
162 }
163 });
164 }));
165 }
166
167 pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
168 self.show_task = None;
169 if self.content.is_none() {
170 return;
171 }
172 let epoch = self.next_epoch();
173 self.had_recent_tooltip = true;
174 self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
175 cx.background_executor().timer(GRACE_PERIOD).await;
176 let _ = this.update_in(cx, |this, _, cx| {
177 if this.epoch == epoch {
178 this.content = None;
179 this.previous_bounds = None;
180 this.had_recent_tooltip = false;
181 cx.notify();
182 }
183 });
184 }));
185 }
186
187 pub fn hide(&mut self, cx: &mut Context<Self>) {
188 let changed = self.content.is_some()
189 || self.previous_bounds.is_some()
190 || self.had_recent_tooltip
191 || self.show_task.is_some()
192 || self.hide_task.is_some();
193 self.content = None;
194 self.previous_bounds = None;
195 self.had_recent_tooltip = false;
196 self.is_switching = false;
197 self.show_task = None;
198 self.hide_task = None;
199 if changed {
200 cx.notify();
201 }
202 }
203}
204
205impl Default for TooltipOverlay {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211impl Render for TooltipOverlay {
212 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
213 let Some(content) = self.content.as_ref() else {
214 return div().into_any_element();
215 };
216 let view = (content.build)(window, cx);
217 let transition = match (self.is_switching, self.previous_bounds) {
218 (true, Some(previous)) => TooltipTransition::Switch {
219 epoch: self.animation_epoch,
220 previous,
221 current: content.trigger_bounds,
222 },
223 _ => TooltipTransition::Enter {
224 epoch: self.animation_epoch,
225 },
226 };
227 let rendered = (self.renderer)(view, transition, window, cx);
228 deferred(
229 TooltipPositioner::new(content.trigger_bounds)
230 .when_some(content.preferred_placement, |this, placement| {
231 this.placement(placement)
232 })
233 .child(rendered),
234 )
235 .with_priority(TOOLTIP_PRIORITY)
236 .into_any_element()
237 }
238}
239
240pub struct TooltipPositioner(Positioner);
245
246impl TooltipPositioner {
247 pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
248 Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
249 }
250
251 pub fn placement(mut self, placement: Placement) -> Self {
252 self.0 = self.0.placement(placement);
253 self
254 }
255}
256
257impl ParentElement for TooltipPositioner {
258 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
259 self.0.extend(elements);
260 }
261}
262
263impl IntoElement for TooltipPositioner {
264 type Element = Positioner;
265
266 fn into_element(self) -> Self::Element {
267 self.0
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use gpui::{AppContext as _, point, size};
275
276 fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
277 Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
278 }
279
280 #[gpui::test]
281 fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
282 let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
283 let cx = cx.add_empty_window();
284 cx.update(|window, cx| {
285 state.update(cx, |tooltip, cx| {
286 tooltip.had_recent_tooltip = true;
287 tooltip.request_show(
288 TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
289 panic!("content is not rendered by this lifecycle test")
290 }),
291 window,
292 cx,
293 );
294 });
295 });
296 cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
297
298 cx.update(|_, cx| {
299 state.update(cx, |tooltip, cx| tooltip.hide(cx));
300 });
301 cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
302 }
303
304 #[test]
305 fn tooltip_priority_exceeds_popup_layer() {
306 assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
307 }
308}