Skip to main content

ui/components/
chart.rs

1//! GPUI `canvas()`-based charts: Bar, Line, Area, and Pie.
2//!
3//! ## Deferred (not faked)
4//!
5//! Radar, composed, and scatter chart types are explicitly out of scope —
6//! Recharts parity for those would require substantially more layout/series
7//! machinery than this hand-rolled primitive set.
8
9use gpui::{Bounds, Hsla, PathBuilder, canvas, fill, point, px};
10
11use crate::prelude::*;
12
13/// Basic chart kinds supported by this module.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ChartKind {
16    #[default]
17    Bar,
18    Line,
19    Area,
20    Pie,
21}
22
23/// Default chart color roles mapped from the design-system palette.
24pub fn chart_colors() -> [Hsla; 5] {
25    [
26        palette::primary(500),
27        palette::info(500),
28        palette::success(500),
29        palette::warning(500),
30        palette::danger(500),
31    ]
32}
33
34/// A lightweight data-in chart rendered via GPUI `canvas()`.
35#[derive(IntoElement, RegisterComponent)]
36pub struct Chart {
37    kind: ChartKind,
38    labels: Vec<SharedString>,
39    values: Vec<f32>,
40    colors: Vec<Hsla>,
41    height: Pixels,
42}
43
44impl Chart {
45    pub fn new(kind: ChartKind, labels: Vec<SharedString>, values: Vec<f32>) -> Self {
46        let colors: Vec<Hsla> = values
47            .iter()
48            .enumerate()
49            .map(|(i, _)| chart_colors()[i % chart_colors().len()])
50            .collect();
51        Self {
52            kind,
53            labels,
54            values,
55            colors,
56            height: px(200.),
57        }
58    }
59
60    pub fn bar(labels: Vec<SharedString>, values: Vec<f32>) -> Self {
61        Self::new(ChartKind::Bar, labels, values)
62    }
63
64    pub fn line(labels: Vec<SharedString>, values: Vec<f32>) -> Self {
65        Self::new(ChartKind::Line, labels, values)
66    }
67
68    pub fn area(labels: Vec<SharedString>, values: Vec<f32>) -> Self {
69        Self::new(ChartKind::Area, labels, values)
70    }
71
72    pub fn pie(labels: Vec<SharedString>, values: Vec<f32>) -> Self {
73        Self::new(ChartKind::Pie, labels, values)
74    }
75
76    pub fn height(mut self, height: Pixels) -> Self {
77        self.height = height;
78        self
79    }
80
81    pub fn colors(mut self, colors: Vec<Hsla>) -> Self {
82        self.colors = colors;
83        self
84    }
85}
86
87fn draw_bar_chart(bounds: Bounds<Pixels>, values: &[f32], colors: &[Hsla], window: &mut Window) {
88    if values.is_empty() {
89        return;
90    }
91    let max = values.iter().copied().fold(0.0f32, f32::max).max(1.0);
92    let padding = px(16.);
93    let chart_width = bounds.size.width - padding * 2.;
94    let chart_height = bounds.size.height - padding * 2.;
95    let bar_gap = px(8.);
96    let bar_width = (chart_width - bar_gap * (values.len().saturating_sub(1) as f32))
97        / values.len().max(1) as f32;
98
99    for (i, value) in values.iter().enumerate() {
100        let bar_h = chart_height * (*value / max);
101        let x = bounds.origin.x + padding + (bar_width + bar_gap) * i as f32;
102        let y = bounds.origin.y + padding + chart_height - bar_h;
103        let color = colors.get(i).copied().unwrap_or(palette::primary(500));
104        window.paint_quad(fill(
105            Bounds::from_corners(point(x, y), point(x + bar_width, y + bar_h)),
106            color,
107        ));
108    }
109}
110
111fn draw_line_or_area(
112    bounds: Bounds<Pixels>,
113    values: &[f32],
114    color: Hsla,
115    fill_area: bool,
116    window: &mut Window,
117) {
118    if values.len() < 2 {
119        return;
120    }
121    let max = values.iter().copied().fold(0.0f32, f32::max).max(1.0);
122    let padding = px(16.);
123    let chart_width = bounds.size.width - padding * 2.;
124    let chart_height = bounds.size.height - padding * 2.;
125    let step = chart_width / (values.len() - 1) as f32;
126
127    let points: Vec<_> = values
128        .iter()
129        .enumerate()
130        .map(|(i, v)| {
131            let x = bounds.origin.x + padding + step * i as f32;
132            let y = bounds.origin.y + padding + chart_height * (1.0 - v / max);
133            point(x, y)
134        })
135        .collect();
136
137    if fill_area {
138        let mut builder = PathBuilder::fill();
139        let baseline_y = bounds.origin.y + padding + chart_height;
140        builder.move_to(point(points[0].x, baseline_y));
141        for p in &points {
142            builder.line_to(*p);
143        }
144        builder.line_to(point(points.last().unwrap().x, baseline_y));
145        builder.close();
146        if let Ok(path) = builder.build() {
147            window.paint_path(path, color.opacity(0.25));
148        }
149    }
150
151    let mut stroke = PathBuilder::stroke(px(2.));
152    stroke.move_to(points[0]);
153    for p in points.iter().skip(1) {
154        stroke.line_to(*p);
155    }
156    if let Ok(path) = stroke.build() {
157        window.paint_path(path, color);
158    }
159}
160
161fn draw_pie_chart(bounds: Bounds<Pixels>, values: &[f32], colors: &[Hsla], window: &mut Window) {
162    let total: f32 = values.iter().sum();
163    if total <= 0.0 {
164        return;
165    }
166    let center = point(
167        bounds.origin.x + bounds.size.width / 2.,
168        bounds.origin.y + bounds.size.height / 2.,
169    );
170    let radius = (bounds.size.width.min(bounds.size.height) / 2.) - px(16.);
171    let mut start_angle = -std::f32::consts::FRAC_PI_2;
172
173    for (i, value) in values.iter().enumerate() {
174        let sweep = std::f32::consts::TAU * (value / total);
175        let end_angle = start_angle + sweep;
176        let color = colors.get(i).copied().unwrap_or(palette::primary(500));
177
178        let mut builder = PathBuilder::fill();
179        builder.move_to(center);
180        let start = point(
181            center.x + radius * start_angle.cos(),
182            center.y + radius * start_angle.sin(),
183        );
184        builder.line_to(start);
185        builder.arc_to(
186            point(radius, radius),
187            px(0.),
188            sweep.abs() > std::f32::consts::PI,
189            sweep > 0.0,
190            point(
191                center.x + radius * end_angle.cos(),
192                center.y + radius * end_angle.sin(),
193            ),
194        );
195        builder.close();
196        if let Ok(path) = builder.build() {
197            window.paint_path(path, color);
198        }
199        start_angle = end_angle;
200    }
201}
202
203impl RenderOnce for Chart {
204    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
205        let kind = self.kind;
206        let values = self.values;
207        let colors = self.colors.clone();
208        let legend_colors = self.colors;
209        let labels = self.labels;
210        let height = self.height;
211        let border = semantic::border(cx);
212        let canvas_colors = colors.clone();
213
214        v_flex()
215            .w_full()
216            .gap_2()
217            // Test-only, no-op in release builds (mirrors the
218            // `Tab`/`ContextMenu` `debug_selector` precedent) — `Chart` has
219            // no interactive state to assert, so integration tests use this
220            // to confirm each kind actually rendered (real bounds) rather
221            // than merely not panicking.
222            .debug_selector(move || format!("CHART-{kind:?}"))
223            .child(
224                canvas(
225                    |_, _, _| {},
226                    move |bounds, _, window, _cx| match kind {
227                        ChartKind::Bar => draw_bar_chart(bounds, &values, &canvas_colors, window),
228                        ChartKind::Line => draw_line_or_area(
229                            bounds,
230                            &values,
231                            canvas_colors
232                                .first()
233                                .copied()
234                                .unwrap_or(palette::primary(500)),
235                            false,
236                            window,
237                        ),
238                        ChartKind::Area => draw_line_or_area(
239                            bounds,
240                            &values,
241                            canvas_colors
242                                .first()
243                                .copied()
244                                .unwrap_or(palette::primary(500)),
245                            true,
246                            window,
247                        ),
248                        ChartKind::Pie => draw_pie_chart(bounds, &values, &canvas_colors, window),
249                    },
250                )
251                .w_full()
252                .h(height)
253                .rounded_md()
254                .border_1()
255                .border_color(border),
256            )
257            .when(!labels.is_empty(), |this| {
258                this.child(h_flex().gap_3().flex_wrap().children(
259                    labels.into_iter().enumerate().map(|(i, label)| {
260                        let color = legend_colors
261                            .get(i)
262                            .copied()
263                            .unwrap_or(palette::neutral(400));
264                        h_flex()
265                            .gap_1()
266                            .items_center()
267                            .child(div().size_2().rounded_full().bg(color))
268                            .child(
269                                Label::new(label)
270                                    .size(LabelSize::XSmall)
271                                    .color(Color::Muted),
272                            )
273                    }),
274                ))
275            })
276    }
277}
278
279impl Component for Chart {
280    fn scope() -> ComponentScope {
281        ComponentScope::DataDisplay
282    }
283
284    fn description() -> Option<&'static str> {
285        Some("Bar/Line/Area/Pie charts via GPUI canvas(); Radar/composed/scatter deferred.")
286    }
287
288    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
289        let labels = vec!["Jan".into(), "Feb".into(), "Mar".into(), "Apr".into()];
290        let values = vec![12.0, 18.0, 9.0, 22.0];
291        Some(
292            v_flex()
293                .gap_6()
294                .child(Chart::bar(labels.clone(), values.clone()))
295                .child(Chart::line(labels.clone(), values.clone()))
296                .child(Chart::area(labels.clone(), values.clone()))
297                .child(Chart::pie(labels, values))
298                .into_any_element(),
299        )
300    }
301}