Skip to main content

gpui_component/chart/
line_chart.rs

1use std::{hash::Hash, rc::Rc};
2
3use gpui::{
4    AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Size,
5    Window, point, px,
6};
7use gpui_component_macros::IntoPlot;
8
9use crate::{
10    ActiveTheme,
11    plot::{
12        AxisLabelPlacement, Curve, PathCaches, Plot, PlotAppear, PlotAxis,
13        scale::{PlotValue, Scale, ScaleLinear, ScalePoint},
14        shape::Line,
15        tooltip::{CrossLine, Dot, Tooltip, TooltipState},
16    },
17};
18
19use super::{
20    AXIS_GAP, ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent,
21    axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask,
22    point_range, point_value_scale, reveal_mask,
23};
24
25#[derive(IntoPlot)]
26pub struct LineChart<T, X, Y>
27where
28    T: 'static,
29    X: PartialEq + Into<SharedString> + 'static,
30    Y: PlotValue,
31{
32    data: Vec<T>,
33    x: Option<Rc<dyn Fn(&T) -> X>>,
34    y: Option<Rc<dyn Fn(&T) -> Y>>,
35    stroke: Option<Hsla>,
36    curve: Curve,
37    dot: bool,
38    tick_margin: usize,
39    x_axis: bool,
40    grid: bool,
41    y_domain: Option<(Y, Y)>,
42    point_count: Option<usize>,
43    axes: PointAxes,
44    id: ElementId,
45    interactive: bool,
46    appear: ChartAppear,
47    name: Option<SharedString>,
48    tooltip_content: TooltipContent<T>,
49}
50
51impl<T, X, Y> LineChart<T, X, Y>
52where
53    X: PartialEq + Into<SharedString> + 'static,
54    Y: PlotValue,
55{
56    #[track_caller]
57    pub fn new<I>(data: I) -> Self
58    where
59        I: IntoIterator<Item = T>,
60    {
61        Self {
62            data: data.into_iter().collect(),
63            stroke: None,
64            curve: Default::default(),
65            dot: false,
66            x: None,
67            y: None,
68            tick_margin: 1,
69            x_axis: true,
70            grid: true,
71            y_domain: None,
72            point_count: None,
73            axes: PointAxes::default(),
74            id: caller_id(),
75            interactive: true,
76            appear: ChartAppear::default(),
77            name: None,
78            tooltip_content: TooltipContent::default(),
79        }
80    }
81
82    /// Name this chart's [`ElementId`], replacing the default taken from the
83    /// construction site.
84    ///
85    /// Pass one where a single construction site renders several of these
86    /// charts as siblings: they share the default id, and with it one hover
87    /// state and one path cache. The id must be unique among those siblings.
88    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
89        self.id = id.into();
90        self
91    }
92
93    /// Turn this chart's interactive layer on or off. On by default.
94    ///
95    /// The layer is the hitbox under the cursor and what it drives: a crosshair
96    /// and a dot mark the hovered point, and a tooltip shows its value. Turn it
97    /// off for a chart that only decorates, or one an element above it wants the
98    /// cursor for: without a hitbox it neither answers the mouse nor takes the
99    /// hover from what sits over it.
100    pub fn interactive(mut self, interactive: bool) -> Self {
101        self.interactive = interactive;
102        self
103    }
104
105    /// Draw the data in the first time this chart is painted. On by default.
106    ///
107    /// The theme sets how long it takes, and the system's reduced-motion
108    /// setting skips it. Turn it off for a chart that is painted again and
109    /// again as it scrolls in and out of view, such as one in each row of a
110    /// long list, where it would draw in every time.
111    pub fn appear(mut self, appear: bool) -> Self {
112        self.appear.set_enabled(appear);
113        self
114    }
115
116    /// Draw the data in again whenever `key` changes, such as the symbol or
117    /// period a chart shows.
118    ///
119    /// Without one the data draws in once, and later data paints in place.
120    pub fn appear_key(mut self, key: impl Hash) -> Self {
121        self.appear.set_key(key);
122        self
123    }
124
125    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
126    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
127        self.name = Some(name.into());
128        self
129    }
130
131    /// Set the hover tooltip's title for a datum, instead of its x value.
132    pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
133        self.tooltip_content.set_title(title);
134        self
135    }
136
137    /// Set the text of the tooltip row's value; the raw number by default.
138    ///
139    /// The closure receives the datum and the value the row reads.
140    pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
141        self.tooltip_content.set_value(move |d, _, v| value(d, v));
142        self
143    }
144
145    /// Color the tooltip row's value, such as green or red by its sign; the
146    /// tooltip's text color by default.
147    ///
148    /// The closure receives the same arguments as
149    /// [`tooltip_value`](Self::tooltip_value).
150    pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
151    where
152        H: Into<Hsla>,
153    {
154        self.tooltip_content
155            .set_value_color(move |d, _, value| color(d, value));
156        self
157    }
158
159    /// Draw the tooltip box's content for a datum yourself, in place of the
160    /// title and rows, for a layout they cannot express such as a table.
161    ///
162    /// The crosshair, the dots and where the box sits stay the chart's, and
163    /// [`tooltip_title`](Self::tooltip_title), [`tooltip_value`](Self::tooltip_value)
164    /// and [`tooltip_value_color`](Self::tooltip_value_color) no longer apply.
165    pub fn tooltip_content<E>(
166        mut self,
167        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
168    ) -> Self
169    where
170        E: IntoElement,
171    {
172        self.tooltip_content.set_content(content);
173        self
174    }
175
176    pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
177        self.x = Some(Rc::new(x));
178        self
179    }
180
181    pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
182        self.y = Some(Rc::new(y));
183        self
184    }
185
186    pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
187        self.stroke = Some(stroke.into());
188        self
189    }
190
191    pub fn natural(mut self) -> Self {
192        self.curve = Curve::Natural;
193        self
194    }
195
196    pub fn linear(mut self) -> Self {
197        self.curve = Curve::Linear;
198        self
199    }
200
201    pub fn step_after(mut self) -> Self {
202        self.curve = Curve::StepAfter;
203        self
204    }
205
206    pub fn dot(mut self) -> Self {
207        self.dot = true;
208        self
209    }
210
211    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
212        self.tick_margin = tick_margin;
213        self
214    }
215
216    /// Show or hide the x-axis line and labels.
217    ///
218    /// Default is true.
219    pub fn x_axis(mut self, x_axis: bool) -> Self {
220        self.x_axis = x_axis;
221        self
222    }
223
224    pub fn grid(mut self, grid: bool) -> Self {
225        self.grid = grid;
226        self
227    }
228
229    /// Pin the y axis to `min..=max` instead of fitting the line from zero.
230    ///
231    /// Pin it where zero is not a meaningful baseline, such as a price line
232    /// that would otherwise be pressed flat against the top. The range keeps
233    /// the `y_padding` headroom above `max`, 10px by default,
234    /// and the line is clipped to the plot, so a value outside the range stops
235    /// at its edge. Nothing is drawn when `min` equals `max`.
236    pub fn y_domain(mut self, min: Y, max: Y) -> Self {
237        self.y_domain = Some((min, max));
238        self
239    }
240
241    /// Lay the x axis out for `count` evenly spaced points instead of the
242    /// data's own length.
243    ///
244    /// The data takes the leading points in order, the i-th item on the i-th
245    /// point, and the rest stay empty, as an intraday chart does before the
246    /// close. The data has to be contiguous from the first point: a missing
247    /// item shifts every later one a point to the left. A `count` below the
248    /// data's length has no effect.
249    pub fn point_count(mut self, count: usize) -> Self {
250        self.point_count = Some(count);
251        self
252    }
253
254    /// Show the y axis's tick labels, one at each of the `y_tick_count` ticks.
255    ///
256    /// Default is false.
257    pub fn y_axis(mut self, y_axis: bool) -> Self {
258        self.axes.y_axis = y_axis;
259        self
260    }
261
262    /// Set where the y-axis tick labels sit: in a gutter left of the plot, or
263    /// inside it beside their grid lines.
264    ///
265    /// Default is [`AxisLabelPlacement::Outside`].
266    pub fn y_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
267        self.axes.y_axis_label_placement = placement;
268        self
269    }
270
271    /// Set how many ticks the y axis carries, evenly spaced from the baseline
272    /// to the top edge with both ends included.
273    ///
274    /// The ticks place the horizontal grid lines and the tick labels, and each
275    /// label reads the value the scale puts at its height. Values below 2 are
276    /// raised to 2.
277    ///
278    /// Default is 5.
279    pub fn y_tick_count(mut self, count: usize) -> Self {
280        self.axes.y_tick_count = count.max(2);
281        self
282    }
283
284    /// Set the text of each y-axis tick label from the value at its tick.
285    pub fn y_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
286    where
287        S: Into<SharedString> + 'static,
288    {
289        self.axes.y_tick_format = Some(Rc::new(move |value| format(value).into()));
290        self
291    }
292
293    /// Label `count` of the x values, spread evenly from the first to the
294    /// last, instead of every `tick_margin`-th.
295    ///
296    /// With [`Self::point_count`] set, the labels spread over all the points the
297    /// axis is laid out for, so they keep their places as the data grows; one
298    /// that falls past the data is not drawn yet.
299    pub fn x_tick_count(mut self, count: usize) -> Self {
300        self.axes.x_tick_count = Some(count);
301        self
302    }
303
304    /// Divide the plot into `count` columns with vertical grid lines, the first
305    /// on its left edge.
306    ///
307    /// Default is 0, no vertical lines.
308    pub fn grid_columns(mut self, count: usize) -> Self {
309        self.axes.grid_columns = count;
310        self
311    }
312
313    /// Draw the grid dashed or solid.
314    ///
315    /// Default is true.
316    pub fn grid_dashed(mut self, dashed: bool) -> Self {
317        self.axes.grid_dashed = dashed;
318        self
319    }
320
321    /// Draw a dashed line across the plot at `value`, such as a previous close.
322    ///
323    /// Call again for more lines. A value outside the y axis is not drawn.
324    pub fn reference_line(mut self, value: Y) -> Self {
325        if let Some(value) = value.to_f64() {
326            self.axes.reference_lines.push(value);
327        }
328        self
329    }
330
331    /// Set the space kept clear above the highest value and below the lowest,
332    /// in pixels.
333    ///
334    /// Default is 10px above and none below.
335    pub fn y_padding(mut self, top: f32, bottom: f32) -> Self {
336        self.axes.y_padding = (top, bottom);
337        self
338    }
339
340    /// Build the x (point) and y (linear) scales for the given bounds.
341    ///
342    /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when the
343    /// x/y accessors have not been set.
344    fn scales(
345        &self,
346        bounds: Bounds<Pixels>,
347    ) -> Option<(ScalePoint<X>, ScaleLinear<Y>, ValueExtent)> {
348        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
349
350        let width = bounds.size.width.as_f32();
351        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
352        let height = bounds.size.height.as_f32() - axis_gap;
353
354        let len = self.data.len();
355        let x = ScalePoint::new(
356            self.data.iter().map(|v| x_fn(v)),
357            point_range(
358                self.axes.plot_left(),
359                width - self.axes.plot_left(),
360                len,
361                axis_point_count(self.point_count, len),
362            ),
363        );
364        let (y, extent) = point_value_scale(
365            self.data.iter().map(|v| y_fn(v)),
366            self.y_domain,
367            height,
368            self.axes.y_padding,
369        );
370
371        Some((x, y, extent))
372    }
373}
374
375impl<T, X, Y> Plot for LineChart<T, X, Y>
376where
377    X: PartialEq + Into<SharedString> + 'static,
378    Y: PlotValue,
379{
380    fn prepaint(
381        &mut self,
382        bounds: Bounds<Pixels>,
383        window: &mut Window,
384        _cx: &mut App,
385    ) -> Vec<AnyElement> {
386        // The y labels' gutter is measured before the x scale is laid out past it.
387        if let Some((_, _, extent)) = self.scales(bounds) {
388            let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
389            let height = bounds.size.height.as_f32() - axis_gap;
390            self.axes.measure_y_labels(extent, height, window);
391        }
392        vec![]
393    }
394
395    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
396        let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
397            return;
398        };
399        let Some((x, y, extent)) = self.scales(bounds) else {
400            return;
401        };
402
403        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
404        let height = bounds.size.height.as_f32() - axis_gap;
405
406        // Draw X axis
407        // The axis runs under the plot only, clear of a value-axis gutter, so
408        // its labels shift back by the gutter the x scale already includes.
409        let left = self.axes.plot_left();
410        let axis_bounds = Bounds {
411            origin: bounds.origin + point(px(left), px(0.)),
412            size: Size::new(bounds.size.width - px(left), bounds.size.height),
413        };
414        let mut axis = PlotAxis::new().stroke(cx.theme().border);
415        if self.x_axis {
416            let labeled = labeled_items(
417                axis_point_count(self.point_count, self.data.len()),
418                self.axes.x_tick_count,
419                self.tick_margin,
420            );
421            let labels = build_point_x_labels(
422                &self.data,
423                x_fn.as_ref(),
424                &x,
425                axis_point_count(self.point_count, self.data.len()),
426                &labeled,
427                cx.theme().muted_foreground,
428            )
429            .into_iter()
430            .map(|mut label| {
431                label.tick -= px(left);
432                label
433            });
434            axis = axis.x(height).x_label(labels);
435        }
436        axis.paint(&axis_bounds, window, cx);
437
438        if self.grid {
439            self.axes.paint_grid(bounds, height, window, cx);
440        }
441
442        // Draw line
443        let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
444        // The x domain holds one entry per datum, so a point's x is its index's
445        // tick; looking its value up in the domain would make each paint O(n^2).
446        let y_fn = y_fn.clone();
447        let mut line = Line::new()
448            .data(self.data.iter().enumerate())
449            .x(move |(i, _)| x.tick_at(*i))
450            .y(move |(_, d)| y.tick(&y_fn(d)))
451            .stroke(stroke)
452            .curve(self.curve)
453            .stroke_width(2.);
454
455        if self.dot {
456            line = line.dot().dot_size(8.).dot_fill(stroke);
457        }
458
459        let mask = self
460            .y_domain
461            .is_some()
462            .then(|| pinned_plot_mask(bounds, height));
463        // The line draws in from the left under a mask, so its shape, and the
464        // cached path, stay the same on every frame of the appear.
465        let reveal = reveal_mask(bounds, left, self.appear.get().progress());
466        window.with_content_mask(mask, |window| {
467            window.with_content_mask(reveal, |window| {
468                let caches = PathCaches::for_paint("line", window, cx);
469                caches.update(cx, |caches, _| {
470                    line.paint_cached(&bounds, caches.slot(0), window);
471                });
472            });
473        });
474
475        self.axes
476            .paint_reference_lines(extent, bounds, height, window, cx);
477        self.axes.paint_y_labels(extent, bounds, height, window, cx);
478    }
479
480    fn id(&self) -> Option<ElementId> {
481        Some(self.id.clone())
482    }
483
484    fn interactive(&self) -> bool {
485        self.interactive
486    }
487
488    fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) {
489        self.appear.update(appear);
490    }
491
492    fn appear_generation(&self) -> Option<u64> {
493        self.appear.generation()
494    }
495
496    fn tooltip_state(
497        &self,
498        position: Point<Pixels>,
499        bounds: Bounds<Pixels>,
500        _cx: &App,
501    ) -> Option<TooltipState> {
502        let y_fn = self.y.as_ref()?;
503        let (x, y, _) = self.scales(bounds)?;
504
505        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
506        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
507        if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
508            || position.x.as_f32() < self.axes.plot_left()
509        {
510            return None;
511        }
512
513        let index = x.nearest_index(position.x.as_f32());
514        let d = self.data.get(index)?;
515        let x_tick = x.tick_at(index)?;
516        let y_tick = y.tick(&y_fn(d))?;
517
518        Some(TooltipState::new(
519            index,
520            point(px(x_tick), position.y),
521            vec![point(px(x_tick), px(y_tick))],
522        ))
523    }
524
525    fn tooltip(
526        &self,
527        state: &TooltipState,
528        cursor: Point<Pixels>,
529        bounds: Bounds<Pixels>,
530        window: &mut Window,
531        cx: &mut App,
532    ) -> Option<AnyElement> {
533        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
534        let d = self.data.get(state.index)?;
535        let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
536        let name = self.name.clone().unwrap_or_default();
537        let dot = *state.dots.first()?;
538
539        // Follow the cursor; the crosshair and dot glide to the data point.
540        let tooltip = Tooltip::new(cursor, bounds.size)
541            .gap(px(8.))
542            // Confine the crosshair to the plot area so it doesn't cross the x-axis.
543            .cross_line(
544                CrossLine::new(state.cross_line)
545                    .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
546            )
547            .dots(Some(
548                Dot::new(dot)
549                    .size(HOVER_DOT_SIZE)
550                    .halo(HOVER_HALO_SIZE)
551                    .stroke(cx.theme().background)
552                    .fill(stroke),
553            ));
554
555        let tooltip = self.tooltip_content.apply(
556            tooltip,
557            d,
558            || Some(x_fn(d).into()),
559            || Some([(stroke, name, y_fn(d).to_f64()?)]),
560            window,
561            cx,
562        )?;
563
564        Some(tooltip.into_any_element())
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use gpui::{Bounds, point, px, size};
571
572    use super::LineChart;
573    use crate::plot::scale::Scale;
574
575    #[test]
576    fn test_f32_values_scale_like_f64() {
577        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)));
578        let chart: LineChart<(usize, f32), String, f32> =
579            LineChart::new([2f32, 4.].into_iter().enumerate())
580                .x(|(i, _)| i.to_string())
581                .y(|(_, v)| *v)
582                .x_axis(false);
583        let (_, y, _) = chart.scales(bounds).unwrap();
584        let y64 = LineChart::new([2f64, 4.].into_iter().enumerate())
585            .x(|(i, _): &(usize, f64)| i.to_string())
586            .y(|(_, v)| *v)
587            .x_axis(false)
588            .scales(bounds)
589            .unwrap()
590            .1;
591        assert_eq!(y.tick(&4.), y64.tick(&4.));
592        assert_eq!(y.tick(&0.), y64.tick(&0.));
593    }
594}