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 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.macos().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.macos().secondary,
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.macos().chart[0]);
423        let x_fn = x_fn.clone();
424        let y_fn = y_fn.clone();
425        let mut line = Line::new()
426            .data(&self.data)
427            .x(move |d| x.tick(&x_fn(d)))
428            .y(move |d| y.tick(&y_fn(d)))
429            .stroke(stroke)
430            .curve(self.curve)
431            .stroke_width(2.);
432
433        if self.dot {
434            line = line.dot().dot_size(8.).dot_fill(stroke);
435        }
436
437        let mask = self
438            .y_domain
439            .is_some()
440            .then(|| pinned_plot_mask(bounds, height));
441        window.with_content_mask(mask, |window| {
442            // Caching hangs off the chart's own id, which only an interactive chart
443            // puts on the stack; without one, siblings would share a slot and thrash
444            // it, so a chart that is off tessellates afresh each paint.
445            if self.interactive {
446                let caches = PathCaches::for_paint("line", window, cx);
447                caches.update(cx, |caches, _| {
448                    line.paint_cached(&bounds, caches.slot(0), window);
449                });
450            } else {
451                line.paint(&bounds, window);
452            }
453        });
454
455        self.axes
456            .paint_reference_lines(extent, bounds, height, window, cx);
457        self.axes.paint_y_labels(extent, bounds, height, window, cx);
458    }
459
460    fn id(&self) -> Option<ElementId> {
461        self.interactive.then(|| self.id.clone())
462    }
463
464    fn tooltip_state(
465        &self,
466        position: Point<Pixels>,
467        bounds: Bounds<Pixels>,
468        _cx: &App,
469    ) -> Option<TooltipState> {
470        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
471        let (x, y, _) = self.scales(bounds)?;
472
473        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
474        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
475        if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
476            || position.x.as_f32() < self.axes.plot_left()
477        {
478            return None;
479        }
480
481        let index = x.nearest_index(position.x.as_f32());
482        let d = self.data.get(index)?;
483        let x_tick = x.tick(&x_fn(d))?;
484        let y_tick = y.tick(&y_fn(d))?;
485
486        Some(TooltipState::new(
487            index,
488            point(px(x_tick), position.y),
489            vec![point(px(x_tick), px(y_tick))],
490        ))
491    }
492
493    fn tooltip(
494        &self,
495        state: &TooltipState,
496        cursor: Point<Pixels>,
497        bounds: Bounds<Pixels>,
498        window: &mut Window,
499        cx: &mut App,
500    ) -> Option<AnyElement> {
501        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
502        let d = self.data.get(state.index)?;
503        let stroke = self.stroke.unwrap_or(cx.macos().chart[0]);
504        let name = self.name.clone().unwrap_or_default();
505        let dot = *state.dots.first()?;
506
507        // Follow the cursor; the crosshair and dot glide to the data point.
508        let tooltip = Tooltip::new(cursor, bounds.size)
509            .gap(px(8.))
510            // Confine the crosshair to the plot area so it doesn't cross the x-axis.
511            .cross_line(
512                CrossLine::new(state.cross_line)
513                    .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
514            )
515            .dots(Some(
516                Dot::new(dot)
517                    .size(HOVER_DOT_SIZE)
518                    .halo(HOVER_HALO_SIZE)
519                    .stroke(cx.macos().background)
520                    .fill(stroke),
521            ));
522
523        let tooltip = self.tooltip_content.apply(
524            tooltip,
525            d,
526            || Some(x_fn(d).into()),
527            || Some([(stroke, name, y_fn(d).to_f64()?)]),
528            window,
529            cx,
530        )?;
531
532        Some(tooltip.into_any_element())
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use gpui_kit::{Bounds, point, px, size};
539
540    use super::LineChart;
541    use crate::plot::scale::Scale;
542
543    #[test]
544    fn test_f32_values_scale_like_f64() {
545        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)));
546        let chart: LineChart<(usize, f32), String, f32> =
547            LineChart::new([2f32, 4.].into_iter().enumerate())
548                .x(|(i, _)| i.to_string())
549                .y(|(_, v)| *v)
550                .x_axis(false);
551        let (_, y, _) = chart.scales(bounds).unwrap();
552        let y64 = LineChart::new([2f64, 4.].into_iter().enumerate())
553            .x(|(i, _): &(usize, f64)| i.to_string())
554            .y(|(_, v)| *v)
555            .x_axis(false)
556            .scales(bounds)
557            .unwrap()
558            .1;
559        assert_eq!(y.tick(&4.), y64.tick(&4.));
560        assert_eq!(y.tick(&0.), y64.tick(&0.));
561    }
562}