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};
5
6use crate::ThemeStyled as _;
7use crate::{ActiveTheme, Colorize, StyledExt, h_flex, v_flex};
8
9#[derive(Default)]
10pub enum CrossLineAxis {
11    #[default]
12    Vertical,
13    Horizontal,
14    Both,
15}
16
17impl CrossLineAxis {
18    /// Returns true if the cross line axis is vertical or both.
19    #[inline]
20    pub fn show_vertical(&self) -> bool {
21        matches!(self, CrossLineAxis::Vertical | CrossLineAxis::Both)
22    }
23
24    /// Returns true if the cross line axis is horizontal or both.
25    #[inline]
26    pub fn show_horizontal(&self) -> bool {
27        matches!(self, CrossLineAxis::Horizontal | CrossLineAxis::Both)
28    }
29}
30
31#[derive(IntoElement)]
32pub struct CrossLine {
33    point: Point<Pixels>,
34    /// Span `(start, length)` of the vertical line along the y axis; `length` of `None`
35    /// spans the full height.
36    vertical: (f32, Option<f32>),
37    /// Span `(start, length)` of the horizontal line along the x axis; `length` of `None`
38    /// spans the full width.
39    horizontal: (f32, Option<f32>),
40    /// Band thickness perpendicular to the line (solid band mode only).
41    thickness: Pixels,
42    /// `true` (default) draws a dashed hairline; `false` a solid band of `thickness`.
43    dashed: bool,
44    direction: CrossLineAxis,
45}
46
47impl CrossLine {
48    pub fn new(point: Point<Pixels>) -> Self {
49        Self {
50            point,
51            vertical: (0., None),
52            horizontal: (0., None),
53            thickness: px(1.),
54            dashed: true,
55            direction: Default::default(),
56        }
57    }
58
59    /// Render a solid translucent highlight band of `thickness` (centered on `point`)
60    /// instead of the default dashed hairline. Use the bar/band width to highlight the
61    /// hovered column or row.
62    pub fn band(mut self, thickness: impl Into<Pixels>) -> Self {
63        self.thickness = thickness.into();
64        self.dashed = false;
65        self
66    }
67
68    /// Set the cross line axis to horizontal.
69    pub fn horizontal(mut self) -> Self {
70        self.direction = CrossLineAxis::Horizontal;
71        self
72    }
73
74    /// Set the cross line axis to both.
75    pub fn both(mut self) -> Self {
76        self.direction = CrossLineAxis::Both;
77        self
78    }
79
80    /// Set the vertical line's length along the y axis (from the top edge).
81    pub fn height(mut self, height: f32) -> Self {
82        self.vertical.1 = Some(height);
83        self
84    }
85
86    /// Set the horizontal line's length along the x axis (from the left edge).
87    pub fn width(mut self, width: f32) -> Self {
88        self.horizontal.1 = Some(width);
89        self
90    }
91
92    /// Confine the vertical line to `[start, start + length]` along the y axis, so it
93    /// stays within the plot area.
94    pub fn span(mut self, start: f32, length: f32) -> Self {
95        self.vertical = (start, Some(length));
96        self
97    }
98
99    /// Confine the horizontal line to `[start, start + length]` along the x axis, so it
100    /// stays within the plot area.
101    pub fn h_span(mut self, start: f32, length: f32) -> Self {
102        self.horizontal = (start, Some(length));
103        self
104    }
105}
106
107impl From<Point<Pixels>> for CrossLine {
108    fn from(value: Point<Pixels>) -> Self {
109        Self::new(value)
110    }
111}
112
113impl CrossLine {
114    /// Build a single line along one axis: `vertical` runs top→bottom at the data point's
115    /// `x`; otherwise left→right at its `y`. A dashed hairline draws a 1px dashed border; a
116    /// solid band fills a `thickness`-wide strip centered on the data point.
117    fn line(&self, vertical: bool, cx: &App) -> Div {
118        let color = if self.dashed {
119            cx.theme().border.mix(cx.theme().foreground, 0.8)
120        } else {
121            cx.theme().foreground.opacity(0.08)
122        };
123        // The dashed hairline is a zero-width strip drawn entirely by its 1px border.
124        let thickness = if self.dashed { px(0.) } else { self.thickness };
125        // Each axis carries its own span so a `both` crosshair can confine the vertical
126        // and horizontal lines independently.
127        let (start, length) = if vertical {
128            self.vertical
129        } else {
130            self.horizontal
131        };
132
133        let el = div().absolute();
134        let el = if vertical {
135            el.left(self.point.x - thickness * 0.5)
136                .w(thickness)
137                .top(px(start))
138                .map(|el| match length {
139                    Some(length) => el.h(px(length)),
140                    None => el.h_full(),
141                })
142        } else {
143            el.top(self.point.y - thickness * 0.5)
144                .h(thickness)
145                .left(px(start))
146                .map(|el| match length {
147                    Some(length) => el.w(px(length)),
148                    None => el.w_full(),
149                })
150        };
151
152        if self.dashed {
153            let el = if vertical {
154                el.border_l_1()
155            } else {
156                el.border_t_1()
157            };
158            el.border_dashed().border_color(color)
159        } else {
160            el.bg(color)
161        }
162    }
163}
164
165impl RenderOnce for CrossLine {
166    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
167        let vertical = self.direction.show_vertical().then(|| self.line(true, cx));
168        let horizontal = self
169            .direction
170            .show_horizontal()
171            .then(|| self.line(false, cx));
172
173        div()
174            .size_full()
175            .absolute()
176            .top_0()
177            .left_0()
178            .children(vertical)
179            .children(horizontal)
180    }
181}
182
183#[derive(IntoElement)]
184pub struct Dot {
185    point: Point<Pixels>,
186    size: Pixels,
187    stroke: Hsla,
188    fill: Hsla,
189}
190
191impl Dot {
192    pub fn new(point: Point<Pixels>) -> Self {
193        Self {
194            point,
195            size: px(6.),
196            stroke: gpui::transparent_black(),
197            fill: gpui::transparent_black(),
198        }
199    }
200
201    /// Set the size of the dot.
202    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
203        self.size = size.into();
204        self
205    }
206
207    /// Set the stroke of the dot.
208    pub fn stroke(mut self, stroke: Hsla) -> Self {
209        self.stroke = stroke;
210        self
211    }
212
213    /// Set the fill of the dot.
214    pub fn fill(mut self, fill: Hsla) -> Self {
215        self.fill = fill;
216        self
217    }
218}
219
220impl RenderOnce for Dot {
221    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
222        let border_width = px(1.);
223        let offset = self.size / 2. - border_width / 2.;
224
225        div()
226            .absolute()
227            .w(self.size)
228            .h(self.size)
229            .rounded_full()
230            .border(border_width)
231            .border_color(self.stroke)
232            .bg(self.fill)
233            .left(self.point.x - offset)
234            .top(self.point.y - offset)
235    }
236}
237
238#[derive(Clone)]
239pub struct TooltipState {
240    pub index: usize,
241    pub cross_line: Point<Pixels>,
242    pub dots: Vec<Point<Pixels>>,
243}
244
245impl TooltipState {
246    pub fn new(index: usize, cross_line: Point<Pixels>, dots: Vec<Point<Pixels>>) -> Self {
247        Self {
248            index,
249            cross_line,
250            dots,
251        }
252    }
253}
254
255/// A single labelled row in a [`Tooltip`]: a colored swatch, a muted label, and a value.
256struct TooltipRow {
257    color: Hsla,
258    label: SharedString,
259    value: SharedString,
260}
261
262#[derive(IntoElement)]
263pub struct Tooltip {
264    base: Div,
265    gap: Pixels,
266    cross_line: Option<CrossLine>,
267    dots: Option<Vec<Dot>>,
268    appearance: bool,
269    title: Option<SharedString>,
270    rows: Vec<TooltipRow>,
271    /// Cursor position the box hugs (relative to the plot origin).
272    cursor: Point<Pixels>,
273    /// Plot size, used to flip the box toward the center near each edge so it never
274    /// overflows the near side.
275    within: Size<Pixels>,
276}
277
278impl Tooltip {
279    /// Create a tooltip whose box follows the cursor at `cursor` within a `within`-sized plot.
280    pub fn new(cursor: Point<Pixels>, within: Size<Pixels>) -> Self {
281        Self {
282            base: v_flex(),
283            gap: px(0.),
284            cross_line: None,
285            dots: None,
286            appearance: true,
287            title: None,
288            rows: Vec::new(),
289            cursor,
290            within,
291        }
292    }
293
294    /// Set a bold title row shown at the top of the tooltip (e.g. the hovered x value).
295    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
296        self.title = Some(title.into());
297        self
298    }
299
300    /// Append a series row: a colored swatch, a muted `label`, and a right-aligned `value`.
301    pub fn row(
302        mut self,
303        color: impl Into<Hsla>,
304        label: impl Into<SharedString>,
305        value: impl Into<SharedString>,
306    ) -> Self {
307        self.rows.push(TooltipRow {
308            color: color.into(),
309            label: label.into(),
310            value: value.into(),
311        });
312        self
313    }
314
315    /// Set the gap of the tooltip.
316    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
317        self.gap = gap.into();
318        self
319    }
320
321    /// Set the cross line of the tooltip.
322    pub fn cross_line(mut self, cross_line: CrossLine) -> Self {
323        self.cross_line = Some(cross_line);
324        self
325    }
326
327    /// Set the dots of the tooltip.
328    pub fn dots(mut self, dots: impl IntoIterator<Item = Dot>) -> Self {
329        self.dots = Some(dots.into_iter().collect());
330        self
331    }
332
333    /// Set the appearance of the tooltip.
334    pub fn appearance(mut self, appearance: bool) -> Self {
335        self.appearance = appearance;
336        self
337    }
338}
339
340impl Styled for Tooltip {
341    fn style(&mut self) -> &mut StyleRefinement {
342        self.base.style()
343    }
344}
345
346impl ParentElement for Tooltip {
347    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
348        self.base.extend(elements);
349    }
350}
351
352impl RenderOnce for Tooltip {
353    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
354        let Tooltip {
355            base,
356            gap,
357            cross_line,
358            dots,
359            appearance,
360            title,
361            rows,
362            cursor,
363            within,
364        } = self;
365
366        // Structured content (title + rows) takes precedence over freeform `base` children.
367        let content = if title.is_some() || !rows.is_empty() {
368            v_flex()
369                .text_sm()
370                .gap_1()
371                .when_some(title, |this, title| {
372                    this.child(div().font_semibold().child(title))
373                })
374                .children(rows.into_iter().map(|row| {
375                    h_flex()
376                        .items_center()
377                        .justify_between()
378                        .gap_3()
379                        .child(
380                            h_flex()
381                                .items_center()
382                                .gap_1p5()
383                                .child(
384                                    div()
385                                        .size_2()
386                                        .rounded(cx.theme().radius.half())
387                                        .bg(row.color),
388                                )
389                                .child(
390                                    div()
391                                        .text_color(cx.theme().muted_foreground)
392                                        .child(row.label),
393                                ),
394                        )
395                        .child(div().child(row.value))
396                }))
397        } else {
398            base
399        };
400
401        div()
402            .size_full()
403            .absolute()
404            .top_0()
405            .left_0()
406            .when_some(cross_line, |this, cross_line| this.child(cross_line))
407            .when_some(dots, |this, dots| this.children(dots))
408            // Only the box is deferred: it can overflow the plot bounds and must paint above
409            // sibling content, while the crosshair and dots stay in the plot's own layer so
410            // they don't cover elements drawn over the plot.
411            .child(deferred(content.map(|mut this| {
412                if !appearance {
413                    return this.size_full().relative();
414                }
415
416                // Default min width only applies when the caller hasn't set one, so a
417                // custom `min_w` isn't clobbered here.
418                let min_w_unset = this.style().min_size.width.is_none();
419
420                // The box hugs the cursor, flipping toward the center near each edge so it
421                // never overflows the near side.
422                this.absolute()
423                    .when(min_w_unset, |c| c.min_w(px(150.)))
424                    .popover_style(cx)
425                    .p_2()
426                    .map(|c| {
427                        if cursor.x < within.width * 0.5 {
428                            c.left(cursor.x + gap)
429                        } else {
430                            c.right(within.width - cursor.x + gap)
431                        }
432                    })
433                    .map(|c| {
434                        if cursor.y < within.height * 0.5 {
435                            c.top(cursor.y + gap)
436                        } else {
437                            c.bottom(within.height - cursor.y + gap)
438                        }
439                    })
440            })))
441    }
442}