Skip to main content

glassy_ui/
tooltip.rs

1//! Paper hex+alpha is grouped as `RRGGBB_AA`.
2#![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/// Inverse-glass tooltip matching Paper `Glassy UI` → Tooltips.
20///
21/// Render it directly for a visible specimen, or attach it to a [`crate::Button`]
22/// with [`crate::Button::tooltip`] for delayed hover behavior.
23#[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/// Paper placements: above / below / start / end of the trigger.
33#[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    /// Override the active app theme for this tooltip only.
89    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}