Skip to main content

gpui_component/chart/
line_chart.rs

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