Skip to main content

gpui_component/plot/
tooltip.rs

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