1#![allow(clippy::unusual_byte_groupings)]
3
4use std::time::Duration;
5
6use crate::motion::{Motion, StyledSlot};
7use crate::theme::{paint, rgb, ActiveTheme, Theme, ThemeKind};
8use gpui::{
9 anchored, deferred, div, point, prelude::*, px, relative, App, Corner, Div, FontWeight,
10 IntoElement, Pixels, RenderOnce, SharedString, Stateful, StyleRefinement, Styled, Window,
11};
12
13use crate::chrome::box_shadow;
14use crate::compat::{AccessibilityExt, Role, StyleCompatExt};
15
16const DEFAULT_SHOW_DELAY: Duration = Duration::from_millis(300);
17const TOOLTIP_GAP: f32 = 6.0;
18
19#[derive(Clone, IntoElement)]
24pub struct Tooltip {
25 label: SharedString,
26 theme: Option<Theme>,
27 show_delay: Duration,
28 placement: TooltipPlacement,
29 style: StyleRefinement,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum TooltipPlacement {
35 #[default]
36 Above,
37 Below,
38 Start,
39 End,
40}
41
42impl TooltipPlacement {
43 fn anchor(self) -> Corner {
44 match self {
45 Self::Above => Corner::BottomLeft,
46 Self::Below => Corner::TopLeft,
47 Self::Start => Corner::TopRight,
48 Self::End => Corner::TopLeft,
49 }
50 }
51
52 fn offset(self, gap: Pixels) -> gpui::Point<Pixels> {
53 match self {
54 Self::Above => point(px(0.), -gap),
55 Self::Below => point(px(0.), gap),
56 Self::Start => point(-gap, px(0.)),
57 Self::End => point(gap, px(0.)),
58 }
59 }
60
61 fn marker(self) -> Div {
62 let marker = div().absolute().size(px(0.));
63 match self {
64 Self::Above => marker.left(px(0.)).top(px(0.)),
65 Self::Below => marker.left(px(0.)).top(relative(1.)),
66 Self::Start => marker.left(px(0.)).top(relative(0.5)),
67 Self::End => marker.left(relative(1.)).top(relative(0.5)),
68 }
69 }
70}
71
72struct TooltipAttachState {
73 open: bool,
74 hover_generation: u64,
75}
76
77impl Tooltip {
78 pub fn new(label: impl Into<SharedString>) -> Self {
79 Self {
80 label: label.into(),
81 theme: None,
82 show_delay: DEFAULT_SHOW_DELAY,
83 placement: TooltipPlacement::Above,
84 style: StyleRefinement::default(),
85 }
86 }
87
88 pub fn theme(mut self, theme: Theme) -> Self {
90 self.theme = Some(theme);
91 self
92 }
93
94 pub fn show_delay(mut self, delay: Duration) -> Self {
95 self.show_delay = delay;
96 self
97 }
98
99 pub fn placement(mut self, placement: TooltipPlacement) -> Self {
100 self.placement = placement;
101 self
102 }
103
104 pub fn delay(&self) -> Duration {
105 self.show_delay
106 }
107
108 pub fn placement_kind(&self) -> TooltipPlacement {
109 self.placement
110 }
111
112 pub(crate) fn attach(
113 self,
114 trigger_id: SharedString,
115 trigger: Stateful<Div>,
116 window: &mut Window,
117 cx: &mut App,
118 ) -> impl IntoElement {
119 let delay = self.show_delay;
120 let placement = self.placement;
121 let state_id = SharedString::from(format!("{trigger_id}-tooltip-state"));
122 let chip_selector = format!("{trigger_id}-tooltip");
123 let state = window.use_keyed_state(state_id, cx, |_, _| TooltipAttachState {
124 open: false,
125 hover_generation: 0,
126 });
127 let open = state.read(cx).open;
128 let hover_state = state.clone();
129
130 let trigger = trigger.on_hover(move |hovered, window, cx| {
131 if *hovered {
132 if delay.is_zero() {
133 hover_state.update(cx, |tooltip, cx| {
134 tooltip.open = true;
135 cx.notify();
136 });
137 return;
138 }
139 hover_state.update(cx, |tooltip, _| {
140 tooltip.hover_generation += 1;
141 });
142 let generation = hover_state.read(cx).hover_generation;
143 let pending = hover_state.downgrade();
144 window
145 .spawn(cx, async move |cx| {
146 cx.background_executor().timer(delay).await;
147 pending
148 .update(cx, |tooltip, cx| {
149 if tooltip.hover_generation == generation {
150 tooltip.open = true;
151 cx.notify();
152 }
153 })
154 .ok();
155 })
156 .detach();
157 } else {
158 hover_state.update(cx, |tooltip, cx| {
159 tooltip.hover_generation += 1;
160 tooltip.open = false;
161 cx.notify();
162 });
163 }
164 });
165
166 let gap = px(TOOLTIP_GAP);
167 let surface = Motion::new()
168 .id(format!("{trigger_id}-tooltip-surface"))
169 .surface_in()
170 .child(
171 div()
172 .id(SharedString::from(chip_selector.clone()))
173 .debug_selector(move || chip_selector.clone())
174 .child(self),
175 );
176 let popup = match placement {
177 TooltipPlacement::Above | TooltipPlacement::Below => placement
178 .marker()
179 .child(
180 deferred(
181 anchored()
182 .anchor(placement.anchor())
183 .offset(placement.offset(gap))
184 .snap_to_window_with_margin(px(8.))
185 .child(surface),
186 )
187 .with_priority(4),
188 )
189 .into_any_element(),
190 TooltipPlacement::Start => div()
191 .absolute()
192 .top(px(0.))
193 .bottom(px(0.))
194 .right(relative(1.))
195 .w(px(0.))
196 .flex()
197 .items_center()
198 .justify_end()
199 .child(deferred(div().mr(gap).child(surface)).with_priority(4))
200 .into_any_element(),
201 TooltipPlacement::End => div()
202 .absolute()
203 .top(px(0.))
204 .bottom(px(0.))
205 .left(relative(1.))
206 .w(px(0.))
207 .flex()
208 .items_center()
209 .justify_start()
210 .child(deferred(div().ml(gap).child(surface)).with_priority(4))
211 .into_any_element(),
212 };
213
214 div()
215 .relative()
216 .flex()
217 .flex_none()
218 .self_start()
219 .items_center()
220 .child(trigger)
221 .when(open, |el| el.child(popup))
222 }
223}
224
225impl Styled for Tooltip {
226 fn style(&mut self) -> &mut StyleRefinement {
227 &mut self.style
228 }
229}
230
231impl RenderOnce for Tooltip {
232 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
233 let theme = self.theme.unwrap_or_else(|| cx.theme());
234 let chrome = tooltip_chrome(theme);
235
236 div()
237 .id(SharedString::from(format!("tooltip-{}", self.label)))
238 .h(px(24.))
239 .flex()
240 .items_center()
241 .flex_shrink_0()
242 .px(px(8.))
243 .rounded(px(6.))
244 .border_1()
245 .border_color(chrome.border)
246 .bg(chrome.bg)
247 .shadow(vec![
248 box_shadow(0., 1., chrome.inset, 0., 0.),
249 box_shadow(0., chrome.shadow_y, chrome.shadow, chrome.shadow_blur, 0.),
250 ])
251 .role(Role::Tooltip)
252 .font_family(theme.font_family)
253 .font_weight(FontWeight::MEDIUM)
254 .text_size(px(12.))
255 .line_height(px(16.))
256 .text_color(chrome.fg)
257 .refine_style(&self.style)
258 .child(self.label)
259 }
260}
261
262#[derive(Clone, Copy, Debug)]
263struct TooltipChrome {
264 bg: gpui::Hsla,
265 border: gpui::Hsla,
266 fg: gpui::Hsla,
267 inset: gpui::Hsla,
268 shadow: gpui::Hsla,
269 shadow_y: f32,
270 shadow_blur: f32,
271}
272
273fn tooltip_chrome(theme: Theme) -> TooltipChrome {
274 match theme.kind {
275 ThemeKind::Light => TooltipChrome {
276 bg: paint(0x18181B_B8),
277 border: paint(0xFFFFFF_29),
278 fg: rgb(0xFAFAFA),
279 inset: paint(0xFFFFFF_38),
280 shadow: paint(0x0F172A_0A),
281 shadow_y: 8.0,
282 shadow_blur: 20.0,
283 },
284 ThemeKind::Dark => TooltipChrome {
285 bg: rgb(0xFAFAFA),
286 border: paint(0xFFFFFF_B8),
287 fg: rgb(0x18181B),
288 inset: rgb(0xFFFFFF),
289 shadow: paint(0x000000_47),
290 shadow_y: 8.0,
291 shadow_blur: 24.0,
292 },
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn default_delay_matches_paper() {
302 assert_eq!(Tooltip::new("Export PNG").delay(), DEFAULT_SHOW_DELAY);
303 }
304
305 #[test]
306 fn default_placement_is_above() {
307 assert_eq!(
308 Tooltip::new("Export PNG").placement_kind(),
309 TooltipPlacement::Above
310 );
311 }
312
313 #[test]
314 fn light_material_matches_paper() {
315 let chrome = tooltip_chrome(Theme::light());
316 assert_eq!(chrome.bg, paint(0x18181B_B8));
317 assert_eq!(chrome.border, paint(0xFFFFFF_29));
318 assert_eq!(chrome.fg, rgb(0xFAFAFA));
319 assert_eq!(chrome.inset, paint(0xFFFFFF_38));
320 assert_eq!(chrome.shadow, paint(0x0F172A_0A));
321 assert_eq!((chrome.shadow_y, chrome.shadow_blur), (8.0, 20.0));
322 }
323
324 #[test]
325 fn dark_material_matches_paper() {
326 let chrome = tooltip_chrome(Theme::dark());
327 assert_eq!(chrome.bg, rgb(0xFAFAFA));
328 assert_eq!(chrome.border, paint(0xFFFFFF_B8));
329 assert_eq!(chrome.fg, rgb(0x18181B));
330 assert_eq!(chrome.inset, rgb(0xFFFFFF));
331 assert_eq!(chrome.shadow, paint(0x000000_47));
332 assert_eq!((chrome.shadow_y, chrome.shadow_blur), (8.0, 24.0));
333 }
334}