Skip to main content

gpui_macos/chart/
line_chart.rs

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