Skip to main content

gpui_component/plot/
tooltip.rs

1use gpui::{
2    AnyElement, App, Div, ElementId, Half as _, Hsla, IntoElement, ParentElement, Pixels, Point,
3    RenderOnce, SharedString, Size, StyleRefinement, Styled, Window, deferred, div, point,
4    prelude::FluentBuilder, px,
5};
6use gpui_base::motion::spring;
7pub use gpui_base::plot::{PlotHover, TooltipState};
8use gpui_base::plot::{hover_progress, is_hover_entering, pointer_spring};
9
10use crate::ThemeStyled as _;
11use crate::{ActiveTheme, Colorize, StyledExt, h_flex, v_flex};
12
13/// The spring ids a tooltip glides its crosshair with, within the plot's scope.
14const GLIDE: &str = "__plot-tooltip-glide";
15
16#[derive(Default)]
17pub enum CrossLineAxis {
18    #[default]
19    Vertical,
20    Horizontal,
21    Both,
22}
23
24impl CrossLineAxis {
25    /// Returns true if the cross line axis is vertical or both.
26    #[inline]
27    pub fn show_vertical(&self) -> bool {
28        matches!(self, CrossLineAxis::Vertical | CrossLineAxis::Both)
29    }
30
31    /// Returns true if the cross line axis is horizontal or both.
32    #[inline]
33    pub fn show_horizontal(&self) -> bool {
34        matches!(self, CrossLineAxis::Horizontal | CrossLineAxis::Both)
35    }
36}
37
38#[derive(IntoElement)]
39pub struct CrossLine {
40    point: Point<Pixels>,
41    /// Span `(start, length)` of the vertical line along the y axis; `length` of `None`
42    /// spans the full height.
43    vertical: (f32, Option<f32>),
44    /// Span `(start, length)` of the horizontal line along the x axis; `length` of `None`
45    /// spans the full width.
46    horizontal: (f32, Option<f32>),
47    /// Band thickness perpendicular to the line (solid band mode only).
48    thickness: Pixels,
49    /// `true` (default) draws a dashed hairline; `false` a solid band of `thickness`.
50    dashed: bool,
51    direction: CrossLineAxis,
52}
53
54impl CrossLine {
55    pub fn new(point: Point<Pixels>) -> Self {
56        Self {
57            point,
58            vertical: (0., None),
59            horizontal: (0., None),
60            thickness: px(1.),
61            dashed: true,
62            direction: Default::default(),
63        }
64    }
65
66    /// Render a solid translucent highlight band of `thickness` (centered on `point`)
67    /// instead of the default dashed hairline. Use the bar/band width to highlight the
68    /// hovered column or row.
69    pub fn band(mut self, thickness: impl Into<Pixels>) -> Self {
70        self.thickness = thickness.into();
71        self.dashed = false;
72        self
73    }
74
75    /// Set the cross line axis to horizontal.
76    pub fn horizontal(mut self) -> Self {
77        self.direction = CrossLineAxis::Horizontal;
78        self
79    }
80
81    /// Set the cross line axis to both.
82    pub fn both(mut self) -> Self {
83        self.direction = CrossLineAxis::Both;
84        self
85    }
86
87    /// Set the vertical line's length along the y axis (from the top edge).
88    pub fn height(mut self, height: f32) -> Self {
89        self.vertical.1 = Some(height);
90        self
91    }
92
93    /// Set the horizontal line's length along the x axis (from the left edge).
94    pub fn width(mut self, width: f32) -> Self {
95        self.horizontal.1 = Some(width);
96        self
97    }
98
99    /// Confine the vertical line to `[start, start + length]` along the y axis, so it
100    /// stays within the plot area.
101    pub fn span(mut self, start: f32, length: f32) -> Self {
102        self.vertical = (start, Some(length));
103        self
104    }
105
106    /// Confine the horizontal line to `[start, start + length]` along the x axis, so it
107    /// stays within the plot area.
108    pub fn h_span(mut self, start: f32, length: f32) -> Self {
109        self.horizontal = (start, Some(length));
110        self
111    }
112}
113
114impl From<Point<Pixels>> for CrossLine {
115    fn from(value: Point<Pixels>) -> Self {
116        Self::new(value)
117    }
118}
119
120impl CrossLine {
121    /// Build a single line along one axis: `vertical` runs top→bottom at the data point's
122    /// `x`; otherwise left→right at its `y`. A dashed hairline draws a 1px dashed border; a
123    /// solid band fills a `thickness`-wide strip centered on the data point.
124    fn line(&self, vertical: bool, cx: &App) -> Div {
125        let color = if self.dashed {
126            cx.theme().border.mix(cx.theme().foreground, 0.8)
127        } else {
128            cx.theme().foreground.opacity(0.08)
129        };
130        // The dashed hairline is a zero-width strip drawn entirely by its 1px border.
131        let thickness = if self.dashed { px(0.) } else { self.thickness };
132        // Each axis carries its own span so a `both` crosshair can confine the vertical
133        // and horizontal lines independently.
134        let (start, length) = if vertical {
135            self.vertical
136        } else {
137            self.horizontal
138        };
139
140        let el = div().absolute();
141        let el = if vertical {
142            el.left(self.point.x - thickness * 0.5)
143                .w(thickness)
144                .top(px(start))
145                .map(|el| match length {
146                    Some(length) => el.h(px(length)),
147                    None => el.h_full(),
148                })
149        } else {
150            el.top(self.point.y - thickness * 0.5)
151                .h(thickness)
152                .left(px(start))
153                .map(|el| match length {
154                    Some(length) => el.w(px(length)),
155                    None => el.w_full(),
156                })
157        };
158
159        if self.dashed {
160            let el = if vertical {
161                el.border_l_1()
162            } else {
163                el.border_t_1()
164            };
165            el.border_dashed().border_color(color)
166        } else {
167            el.bg(color)
168        }
169    }
170}
171
172impl RenderOnce for CrossLine {
173    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
174        let vertical = self.direction.show_vertical().then(|| self.line(true, cx));
175        let horizontal = self
176            .direction
177            .show_horizontal()
178            .then(|| self.line(false, cx));
179
180        div()
181            .size_full()
182            .absolute()
183            .top_0()
184            .left_0()
185            .children(vertical)
186            .children(horizontal)
187    }
188}
189
190#[derive(IntoElement)]
191pub struct Dot {
192    point: Point<Pixels>,
193    size: Pixels,
194    stroke: Hsla,
195    fill: Hsla,
196    /// Diameter of the translucent ring behind the dot; `None` draws no ring.
197    halo: Option<Pixels>,
198}
199
200impl Dot {
201    pub fn new(point: Point<Pixels>) -> Self {
202        Self {
203            point,
204            size: px(6.),
205            stroke: gpui::transparent_black(),
206            fill: gpui::transparent_black(),
207            halo: None,
208        }
209    }
210
211    /// Set the size of the dot.
212    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
213        self.size = size.into();
214        self
215    }
216
217    /// Draw a translucent ring of the fill color, `size` across, behind the dot,
218    /// which marks the hovered point the way a chart marks its emphasized
219    /// symbol.
220    ///
221    /// `size` is the ring at full hover progress: in a [`Tooltip`] the ring grows out of
222    /// the dot as the hover fades in.
223    pub fn halo(mut self, size: impl Into<Pixels>) -> Self {
224        self.halo = Some(size.into());
225        self
226    }
227
228    /// Set the stroke of the dot.
229    pub fn stroke(mut self, stroke: Hsla) -> Self {
230        self.stroke = stroke;
231        self
232    }
233
234    /// Set the fill of the dot.
235    pub fn fill(mut self, fill: Hsla) -> Self {
236        self.fill = fill;
237        self
238    }
239}
240
241impl RenderOnce for Dot {
242    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
243        let border_width = px(1.);
244        let offset = self.size / 2. - border_width / 2.;
245
246        let dot = div()
247            .absolute()
248            .w(self.size)
249            .h(self.size)
250            .rounded_full()
251            .border(border_width)
252            .border_color(self.stroke)
253            .bg(self.fill)
254            .left(self.point.x - offset)
255            .top(self.point.y - offset);
256
257        // The ring paints first so it sits behind the dot, both centered on the
258        // point.
259        let halo = self.halo.map(|halo| {
260            div()
261                .absolute()
262                .size(halo)
263                .rounded_full()
264                .bg(self.fill.opacity(0.2))
265                .left(self.point.x - halo / 2.)
266                .top(self.point.y - halo / 2.)
267        });
268
269        div().absolute().top_0().left_0().children(halo).child(dot)
270    }
271}
272
273/// A single labelled row in a [`Tooltip`]: an optional colored swatch, a muted label, and a value.
274struct TooltipRow {
275    color: Option<Hsla>,
276    label: SharedString,
277    value: SharedString,
278    value_color: Option<Hsla>,
279}
280
281#[derive(IntoElement)]
282pub struct Tooltip {
283    base: Div,
284    gap: Pixels,
285    cross_line: Option<CrossLine>,
286    dots: Option<Vec<Dot>>,
287    appearance: bool,
288    title: Option<SharedString>,
289    rows: Vec<TooltipRow>,
290    /// Cursor position the box hugs (relative to the plot origin).
291    cursor: Point<Pixels>,
292    /// Plot size, used to flip the box toward the center near each edge so it never
293    /// overflows the near side.
294    within: Size<Pixels>,
295    /// Opacity of the whole overlay when set; see [`Self::progress`].
296    progress: Option<f32>,
297    /// Whether the crosshair and dots glide between data; see [`Self::glide`].
298    glide: bool,
299}
300
301impl Tooltip {
302    /// Create a tooltip whose box follows the cursor at `cursor` within a `within`-sized plot.
303    pub fn new(cursor: Point<Pixels>, within: Size<Pixels>) -> Self {
304        Self {
305            // The same row rhythm the structured content lays out with, so a
306            // tooltip built from freeform children does not have to rediscover
307            // it — and does not read as one solid block when it forgets.
308            base: v_flex().gap_y_1(),
309            gap: px(0.),
310            cross_line: None,
311            dots: None,
312            appearance: true,
313            title: None,
314            rows: Vec::new(),
315            cursor,
316            within,
317            progress: None,
318            glide: true,
319        }
320    }
321
322    /// Glide the crosshair and dots between data, or snap them to each datum.
323    ///
324    /// A tooltip returned from [`Plot::tooltip`](super::Plot::tooltip) slides
325    /// them to the hovered datum on the pointer spring, adopting it on the
326    /// frame the cursor lands instead of travelling from where the last hover
327    /// ended. A crosshair glides along the axis it marks only, so a line that
328    /// also follows the cursor keeps up with it. Turn this off for positions
329    /// the plot already springs itself ([`PlotHover::glide`]).
330    ///
331    /// Default is true.
332    pub fn glide(mut self, glide: bool) -> Self {
333        self.glide = glide;
334        self
335    }
336
337    /// Fade the whole overlay — crosshair, dots and box — to `progress` (`0..=1`).
338    ///
339    /// A tooltip returned from [`Plot::tooltip`](super::Plot::tooltip) already
340    /// follows the plot's hover, easing in when the cursor lands on a datum and
341    /// out after it leaves ([`PlotHover::progress`]); set this to override that,
342    /// or to fade a tooltip rendered outside a plot.
343    pub fn progress(mut self, progress: f32) -> Self {
344        self.progress = Some(progress.clamp(0., 1.));
345        self
346    }
347
348    #[deprecated(since = "0.7.0", note = "use `progress`")]
349    pub fn focus(self, focus: f32) -> Self {
350        self.progress(focus)
351    }
352
353    /// Set a bold title row shown at the top of the tooltip (e.g. the hovered x value).
354    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
355        self.title = Some(title.into());
356        self
357    }
358
359    /// Append a series row: a colored swatch, a muted `label`, and a right-aligned `value`.
360    pub fn row(
361        mut self,
362        color: impl Into<Hsla>,
363        label: impl Into<SharedString>,
364        value: impl Into<SharedString>,
365    ) -> Self {
366        self.rows.push(TooltipRow {
367            color: Some(color.into()),
368            label: label.into(),
369            value: value.into(),
370            value_color: None,
371        });
372        self
373    }
374
375    /// Append a row without a swatch, for a figure no series on the plot draws,
376    /// such as a total or a ratio.
377    ///
378    /// Among series rows its label lines up with theirs; without any, the
379    /// labels sit at the start.
380    pub fn plain_row(
381        mut self,
382        label: impl Into<SharedString>,
383        value: impl Into<SharedString>,
384    ) -> Self {
385        self.rows.push(TooltipRow {
386            color: None,
387            label: label.into(),
388            value: value.into(),
389            value_color: None,
390        });
391        self
392    }
393
394    /// Color the value of the row added last — by [`row`](Self::row) or
395    /// [`plain_row`](Self::plain_row) — such as green or red by its sign. The
396    /// value reads in the tooltip's text color otherwise.
397    ///
398    /// Call it right after the row it colors; before any row it does nothing.
399    pub fn value_color(mut self, color: impl Into<Hsla>) -> Self {
400        if let Some(row) = self.rows.last_mut() {
401            row.value_color = Some(color.into());
402        }
403        self
404    }
405
406    /// Set the gap of the tooltip.
407    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
408        self.gap = gap.into();
409        self
410    }
411
412    /// Set the cross line of the tooltip.
413    pub fn cross_line(mut self, cross_line: CrossLine) -> Self {
414        self.cross_line = Some(cross_line);
415        self
416    }
417
418    /// Set the dots of the tooltip.
419    pub fn dots(mut self, dots: impl IntoIterator<Item = Dot>) -> Self {
420        self.dots = Some(dots.into_iter().collect());
421        self
422    }
423
424    /// Set the appearance of the tooltip.
425    pub fn appearance(mut self, appearance: bool) -> Self {
426        self.appearance = appearance;
427        self
428    }
429}
430
431impl Styled for Tooltip {
432    fn style(&mut self) -> &mut StyleRefinement {
433        self.base.style()
434    }
435}
436
437impl ParentElement for Tooltip {
438    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
439        self.base.extend(elements);
440    }
441}
442
443/// Whether the rows keep a swatch slot: when any has a swatch, so a plain row's
444/// label lines up with the series labels, and not when every row is plain.
445fn has_swatches(rows: &[TooltipRow]) -> bool {
446    rows.iter().any(|row| row.color.is_some())
447}
448
449#[cfg(test)]
450impl Tooltip {
451    pub(crate) fn title_for_test(&self) -> Option<&SharedString> {
452        self.title.as_ref()
453    }
454
455    pub(crate) fn rows_for_test(&self) -> Vec<(SharedString, Option<Hsla>)> {
456        self.rows
457            .iter()
458            .map(|row| (row.value.clone(), row.value_color))
459            .collect()
460    }
461}
462
463impl RenderOnce for Tooltip {
464    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
465        // Rendered within the plot's element scope, so this is the fade the
466        // plot tracked for it this frame; fully opaque outside a plot.
467        let tracked_progress = hover_progress(window, cx);
468        let entering = is_hover_entering(window, cx);
469        let Tooltip {
470            base,
471            gap,
472            mut cross_line,
473            mut dots,
474            appearance,
475            title,
476            rows,
477            cursor,
478            within,
479            progress,
480            glide,
481        } = self;
482        let progress = progress.unwrap_or(tracked_progress);
483
484        if glide {
485            let policy = pointer_spring(cx).with_travel(!entering);
486            if let Some(line) = cross_line.as_mut() {
487                if line.direction.show_vertical() {
488                    line.point.x = spring((GLIDE, "x"), line.point.x, policy, window, cx);
489                }
490                if line.direction.show_horizontal() {
491                    line.point.y = spring((GLIDE, "y"), line.point.y, policy, window, cx);
492                }
493            }
494            for (i, dot) in dots.iter_mut().flatten().enumerate() {
495                dot.point = point(
496                    spring(
497                        ElementId::named_usize("__plot-hover-dot-x", i),
498                        dot.point.x,
499                        policy,
500                        window,
501                        cx,
502                    ),
503                    spring(
504                        ElementId::named_usize("__plot-hover-dot-y", i),
505                        dot.point.y,
506                        policy,
507                        window,
508                        cx,
509                    ),
510                );
511            }
512        }
513        // The ring grows out of the dot as the hover fades in.
514        for dot in dots.iter_mut().flatten() {
515            dot.halo = dot.halo.map(|halo| halo * progress);
516        }
517
518        // Structured content (title + rows) takes precedence over freeform `base` children.
519        let content = if title.is_some() || !rows.is_empty() {
520            let swatched = has_swatches(&rows);
521            v_flex()
522                .gap_1()
523                .when_some(title, |this, title| {
524                    this.child(div().font_semibold().child(title))
525                })
526                .children(rows.into_iter().map(|row| {
527                    h_flex()
528                        .items_center()
529                        .justify_between()
530                        .gap_3()
531                        .child(
532                            h_flex()
533                                .items_center()
534                                .gap_1p5()
535                                .when(swatched, |this| {
536                                    this.child(
537                                        div()
538                                            .size_2()
539                                            .rounded(cx.theme().radius.half())
540                                            .when_some(row.color, |this, color| this.bg(color)),
541                                    )
542                                })
543                                .child(
544                                    div()
545                                        .text_color(cx.theme().muted_foreground)
546                                        .child(row.label),
547                                ),
548                        )
549                        .child(
550                            div()
551                                .when_some(row.value_color, |this, color| this.text_color(color))
552                                .child(row.value),
553                        )
554                }))
555        } else {
556            base
557        };
558        // One size for every tooltip, structured or freeform, boxed or bare: a
559        // transient overlay over dense data reads at the compact tier, and a
560        // per-call-site size is how a dozen charts end up at a dozen sizes.
561        // Content that wants a hierarchy sets it on its own children.
562        let content = content.text_xs();
563
564        div()
565            .size_full()
566            .absolute()
567            .top_0()
568            .left_0()
569            .opacity(progress)
570            .when_some(cross_line, |this, cross_line| this.child(cross_line))
571            .when_some(dots, |this, dots| this.children(dots))
572            // Only the box is deferred: it can overflow the plot bounds and must paint above
573            // sibling content, while the crosshair and dots stay in the plot's own layer so
574            // they don't cover elements drawn over the plot. A deferred draw paints outside
575            // this element's opacity, so the box carries the fade itself.
576            .child(deferred(content.map(|mut this| {
577                if !appearance {
578                    return this.size_full().relative().opacity(progress);
579                }
580
581                // Default min width only applies when the caller hasn't set one, so a
582                // custom `min_w` isn't clobbered here.
583                let min_w_unset = this.style().min_size.width.is_none();
584
585                // The box hugs the cursor, flipping toward the center near each edge so it
586                // never overflows the near side.
587                this.absolute()
588                    .opacity(progress)
589                    .when(min_w_unset, |c| c.min_w(px(150.)))
590                    .popover_style(cx)
591                    .p_2()
592                    .map(|c| {
593                        if cursor.x < within.width * 0.5 {
594                            c.left(cursor.x + gap)
595                        } else {
596                            c.right(within.width - cursor.x + gap)
597                        }
598                    })
599                    .map(|c| {
600                        if cursor.y < within.height * 0.5 {
601                            c.top(cursor.y + gap)
602                        } else {
603                            c.bottom(within.height - cursor.y + gap)
604                        }
605                    })
606            })))
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use gpui::{point, px};
613
614    use super::*;
615
616    #[test]
617    fn a_value_color_colors_only_the_row_added_last() {
618        let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)))
619            .value_color(gpui::red())
620            .row(gpui::blue(), "Open", "1")
621            .row(gpui::blue(), "Close", "2")
622            .value_color(gpui::green());
623        let colors: Vec<_> = tooltip.rows.iter().map(|row| row.value_color).collect();
624        assert_eq!(colors, vec![None, Some(gpui::green())]);
625    }
626
627    #[test]
628    fn a_plain_row_has_no_swatch_and_takes_a_value_color() {
629        let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)))
630            .row(gpui::blue(), "Call", "1")
631            .plain_row("Total", "3")
632            .value_color(gpui::red());
633        let rows: Vec<_> = tooltip
634            .rows
635            .iter()
636            .map(|row| (row.color, row.value_color))
637            .collect();
638        assert_eq!(
639            rows,
640            vec![(Some(gpui::blue()), None), (None, Some(gpui::red()))]
641        );
642    }
643
644    #[test]
645    fn plain_rows_keep_a_swatch_slot_only_beside_series_rows() {
646        let tooltip = || Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)));
647        let mixed = tooltip()
648            .row(gpui::blue(), "Call", "1")
649            .plain_row("Total", "3");
650        let plain = tooltip().plain_row("Total", "3").plain_row("Ratio", "0.5");
651        assert!(has_swatches(&mixed.rows));
652        assert!(!has_swatches(&plain.rows));
653    }
654}