Skip to main content

gpui_macos/plot/
tooltip.rs

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