Skip to main content

gpui_component/chart/
pie_chart.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, TextAlign,
5    Window, point, prelude::FluentBuilder, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::Zero;
10
11use crate::{
12    ActiveTheme,
13    plot::{
14        PathCaches, Plot,
15        label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text},
16        polygon,
17        shape::{Arc, ArcData, Pie},
18        tooltip::{PlotHover, Tooltip, TooltipState},
19    },
20};
21
22/// The default extra gap (in pixels) between `outer_radius` and the label radius.
23const DEFAULT_LABEL_GAP: f32 = 15.;
24
25/// How far the hovered slice moves out past its outer radius, in pixels.
26const HOVER_LIFT: f32 = 6.;
27
28/// How much the slices other than the hovered one fade, as a share of their opacity.
29const HOVER_DIM: f32 = 0.35;
30
31/// The hover a pie chart paints, sampled once per frame in [`Plot::hover`].
32struct PieHover {
33    /// How far each datum's slice has lifted, `0..=1`, springing up on the
34    /// hovered slice and back down on the one the cursor left.
35    lift: Vec<f32>,
36    /// How far the hover has faded in.
37    focus: f32,
38}
39
40#[derive(IntoPlot)]
41pub struct PieChart<T: 'static> {
42    data: Vec<T>,
43    inner_radius: f32,
44    inner_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
45    outer_radius: f32,
46    outer_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
47    pad_angle: f32,
48    value: Option<Rc<dyn Fn(&T) -> f32>>,
49    color: Option<Rc<dyn Fn(&T) -> Hsla>>,
50    label: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
51    label_line_color: Option<Rc<dyn Fn(&T) -> Hsla + 'static>>,
52    label_color: Option<Hsla>,
53    label_gap: f32,
54    id: Option<ElementId>,
55    name: Option<SharedString>,
56    hover: Option<PieHover>,
57}
58
59impl<T> PieChart<T> {
60    pub fn new<I>(data: I) -> Self
61    where
62        I: IntoIterator<Item = T>,
63    {
64        Self {
65            data: data.into_iter().collect(),
66            inner_radius: 0.,
67            inner_radius_fn: None,
68            outer_radius: 0.,
69            outer_radius_fn: None,
70            pad_angle: 0.,
71            value: None,
72            color: None,
73            label: None,
74            label_line_color: None,
75            label_color: None,
76            label_gap: DEFAULT_LABEL_GAP,
77            id: None,
78            name: None,
79            hover: None,
80        }
81    }
82
83    /// Enable an interactive hover tooltip for this chart: the hovered slice
84    /// lifts out of the ring and the tooltip shows its value and share.
85    ///
86    /// The `id` must be unique among sibling elements. Without it, the chart
87    /// stays a non-interactive plot.
88    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
89        self.id = Some(id.into());
90        self
91    }
92
93    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
94    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
95        self.name = Some(name.into());
96        self
97    }
98
99    /// Set the inner radius of the pie chart.
100    pub fn inner_radius(mut self, inner_radius: f32) -> Self {
101        self.inner_radius = inner_radius;
102        self
103    }
104
105    /// Set the inner radius of the pie chart based on the arc data.
106    pub fn inner_radius_fn(
107        mut self,
108        inner_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
109    ) -> Self {
110        self.inner_radius_fn = Some(Rc::new(inner_radius_fn));
111        self
112    }
113
114    fn get_inner_radius(&self, arc: &ArcData<T>) -> f32 {
115        if let Some(inner_radius_fn) = self.inner_radius_fn.as_ref() {
116            inner_radius_fn(arc)
117        } else {
118            self.inner_radius
119        }
120    }
121
122    /// Set the outer radius of the pie chart.
123    pub fn outer_radius(mut self, outer_radius: f32) -> Self {
124        self.outer_radius = outer_radius;
125        self
126    }
127
128    /// Set the outer radius of the pie chart based on the arc data.
129    pub fn outer_radius_fn(
130        mut self,
131        outer_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
132    ) -> Self {
133        self.outer_radius_fn = Some(Rc::new(outer_radius_fn));
134        self
135    }
136
137    /// The outer radius of `arc`'s slice: the per-slice one, or `default`.
138    /// `self.outer_radius` is zero until a caller sets it, so the radius the
139    /// ring is laid out with comes from [`Self::resolve_outer_radius`].
140    fn get_outer_radius(&self, arc: &ArcData<T>, default: f32) -> f32 {
141        if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
142            outer_radius_fn(arc)
143        } else {
144            default
145        }
146    }
147
148    /// Set the pad angle of the pie chart.
149    pub fn pad_angle(mut self, pad_angle: f32) -> Self {
150        self.pad_angle = pad_angle;
151        self
152    }
153
154    pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
155        self.value = Some(Rc::new(value));
156        self
157    }
158
159    /// Set the color of the pie chart.
160    pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
161    where
162        H: Into<Hsla> + 'static,
163    {
164        self.color = Some(Rc::new(move |t| color(t).into()));
165        self
166    }
167
168    /// Set the label text for each slice.
169    ///
170    /// Once set, a "leader line + text" is drawn outside the ring for every
171    /// slice.
172    pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
173        self.label = Some(Rc::new(label));
174        self
175    }
176
177    /// Set the leader line color per slice (defaults to `cx.theme().border`).
178    pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
179        self.label_line_color = Some(Rc::new(color));
180        self
181    }
182
183    /// Set the label text color (defaults to `cx.theme().foreground`).
184    pub fn label_color(mut self, color: Hsla) -> Self {
185        self.label_color = Some(color);
186        self
187    }
188
189    /// Set the extra gap between `outer_radius` and the label radius
190    /// (defaults to 15px).
191    pub fn label_gap(mut self, gap: f32) -> Self {
192        self.label_gap = gap;
193        self
194    }
195
196    /// The outer radius the ring is laid out with: the set one, or 40% of the
197    /// bounds height.
198    fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
199        if self.outer_radius.is_zero() {
200            bounds.size.height.as_f32() * 0.4
201        } else {
202            self.outer_radius
203        }
204    }
205
206    /// The slices, in ring order. Shared by `paint` and `tooltip_state` so the
207    /// two stay in sync; empty without a value accessor.
208    fn arcs(&self) -> Vec<ArcData<'_, T>> {
209        let Some(value_fn) = self.value.clone() else {
210            return vec![];
211        };
212        Pie::<T>::new()
213            .value(move |d| Some(value_fn(d)))
214            .pad_angle(self.pad_angle)
215            .arcs(&self.data)
216    }
217
218    /// The fill of a slice: the per-datum color, or the theme's.
219    fn slice_color(&self, datum: &T, cx: &App) -> Hsla {
220        match self.color.as_ref() {
221            Some(color_fn) => color_fn(datum),
222            None => cx.theme().chart_2,
223        }
224    }
225
226    /// How far the slice of datum `index` has lifted and how much it has faded
227    /// behind the hovered one this frame, as `(lift, opacity)`.
228    fn slice_emphasis(&self, index: usize) -> (f32, f32) {
229        let Some(hover) = self.hover.as_ref() else {
230            return (0., 1.);
231        };
232        let lift = hover.lift.get(index).copied().unwrap_or(0.) * hover.focus;
233        (lift, 1. - HOVER_DIM * hover.focus * (1. - lift))
234    }
235}
236
237impl<T> Plot for PieChart<T> {
238    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
239        if self.value.is_none() {
240            return;
241        }
242
243        let outer_radius = self.resolve_outer_radius(&bounds);
244
245        let arc = Arc::new()
246            .inner_radius(self.inner_radius)
247            .outer_radius(outer_radius);
248        let arcs = self.arcs();
249
250        // An identified chart keeps its slices tessellated across frames; without
251        // an id, sibling charts would share one cache and thrash it.
252        let caches = self
253            .id
254            .is_some()
255            .then(|| PathCaches::for_paint("slices", window, cx));
256        for (ix, a) in arcs.iter().enumerate() {
257            let inner_radius = self.get_inner_radius(a);
258            // The hovered slice lifts out of the ring while the others fade behind it.
259            let (lift, opacity) = self.slice_emphasis(a.index);
260            let slice_radius = self.get_outer_radius(a, outer_radius) + HOVER_LIFT * lift;
261            let color = self.slice_color(a.data, cx).opacity(opacity);
262            match caches.as_ref() {
263                Some(caches) => caches.update(cx, |caches, _| {
264                    arc.paint_cached(
265                        a,
266                        color,
267                        Some(inner_radius),
268                        Some(slice_radius),
269                        &bounds,
270                        caches.slot(ix),
271                        window,
272                    );
273                }),
274                None => arc.paint(
275                    a,
276                    color,
277                    Some(inner_radius),
278                    Some(slice_radius),
279                    &bounds,
280                    window,
281                ),
282            }
283        }
284
285        // Draw leader-line labels outside the ring (only when `label` is set).
286        let Some(label_fn) = self.label.as_ref() else {
287            return;
288        };
289
290        let label_radius = outer_radius + self.label_gap;
291        let center_x = bounds.size.width.as_f32() / 2.;
292        let center_y = bounds.size.height.as_f32() / 2.;
293        let label_arc = Arc::new()
294            .inner_radius(label_radius)
295            .outer_radius(label_radius);
296
297        let label_color = self.label_color.unwrap_or(cx.theme().foreground);
298        let default_line_color = cx.theme().border;
299
300        // First pass: collect a layout candidate per visible slice, split by
301        // side. `y` is the target vertical position relative to the center and
302        // gets adjusted later to remove overlaps.
303        let mut right: Vec<LabelLayout> = vec![];
304        let mut left: Vec<LabelLayout> = vec![];
305        for a in &arcs {
306            // Skip tiny slices (< 0.5°) that are too thin to label.
307            if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
308                continue;
309            }
310
311            let centroid = label_arc.centroid(a);
312            // Anchor the line on the edge the slice reaches this frame, so a
313            // lifted slice never paints over its own leader line. The label
314            // anchor stays put, so the line may not start past it.
315            let (lift, _) = self.slice_emphasis(a.index);
316            let edge_radius = (outer_radius + HOVER_LIFT * lift).min(label_radius);
317            let edge = Arc::new()
318                .inner_radius(edge_radius)
319                .outer_radius(edge_radius)
320                .centroid(a);
321            let is_right = centroid.x > 0.;
322            let line_color = self
323                .label_line_color
324                .as_ref()
325                .map(|f| f(a.data))
326                .unwrap_or(default_line_color);
327
328            let layout = LabelLayout {
329                arc_x: edge.x,
330                arc_y: edge.y,
331                label_x: centroid.x,
332                y: centroid.y,
333                text: label_fn(a.data),
334                line_color,
335            };
336            if is_right { &mut right } else { &mut left }.push(layout);
337        }
338
339        // Second pass: spread labels on each side so neighbors keep at least one
340        // text height apart, clamped within the vertical bounds.
341        let top = -center_y + TEXT_HEIGHT / 2.;
342        let bottom = center_y - TEXT_HEIGHT / 2.;
343        spread_labels(&mut right, top, bottom);
344        spread_labels(&mut left, top, bottom);
345
346        // Third pass: paint leader lines first, then the text on top.
347        let mut labels = vec![];
348        for (side, items) in [(1., &right), (-1., &left)] {
349            for item in items {
350                // Leader line: ring edge -> label anchor -> horizontal pull to
351                // ±label_radius.
352                let pts = [
353                    point(item.arc_x + center_x, item.arc_y + center_y),
354                    point(item.label_x + center_x, item.y + center_y),
355                    point(side * label_radius + center_x, item.y + center_y),
356                ];
357                if let Some(p) = polygon(&pts, &bounds) {
358                    window.paint_path(p, item.line_color);
359                }
360
361                // Text sits 4px further out, aligned by side.
362                let origin = point(
363                    side * (label_radius + 4.) + center_x,
364                    item.y - TEXT_SIZE / 2. + center_y,
365                );
366                let align = if side > 0. {
367                    TextAlign::Left
368                } else {
369                    TextAlign::Right
370                };
371                labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
372            }
373        }
374
375        PlotLabel::new(labels).paint(&bounds, window, cx);
376    }
377
378    fn id(&self) -> Option<ElementId> {
379        self.id.clone()
380    }
381
382    fn tooltip_state(
383        &self,
384        position: Point<Pixels>,
385        bounds: Bounds<Pixels>,
386        _cx: &App,
387    ) -> Option<TooltipState> {
388        let outer_radius = self.resolve_outer_radius(&bounds);
389        let arc = Arc::new()
390            .inner_radius(self.inner_radius)
391            .outer_radius(outer_radius);
392        let position = point(position.x.as_f32(), position.y.as_f32());
393
394        let index = self.arcs().into_iter().find_map(|a| {
395            arc.contains(
396                &a,
397                position,
398                Some(self.get_inner_radius(&a)),
399                Some(self.get_outer_radius(&a, outer_radius)),
400                &bounds,
401            )
402            .then_some(a.index)
403        })?;
404
405        Some(TooltipState::new(
406            index,
407            point(px(position.x), px(position.y)),
408            vec![],
409        ))
410    }
411
412    fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
413        self.hover = hover.map(|hover| {
414            // Every slice springs toward lifted or resting, so the one the cursor
415            // left settles back while the new one rises. On the first hovered
416            // frame the target is rest, so the slice rises from the ring rather
417            // than adopting the lifted position outright.
418            let policy = cx.theme().motion_tokens().spring_control;
419            let lift = (0..self.data.len())
420                .map(|ix| {
421                    let lifted =
422                        hover.is_hovered() && !hover.is_entering() && ix == hover.state().index;
423                    spring(
424                        ElementId::named_usize("pie-slice", ix),
425                        if lifted { 1. } else { 0. },
426                        policy,
427                        window,
428                        cx,
429                    )
430                })
431                .collect();
432            PieHover {
433                lift,
434                focus: hover.focus(),
435            }
436        });
437    }
438
439    fn tooltip(
440        &self,
441        state: &TooltipState,
442        cursor: Point<Pixels>,
443        bounds: Bounds<Pixels>,
444        _window: &mut Window,
445        cx: &mut App,
446    ) -> Option<AnyElement> {
447        let value_fn = self.value.as_ref()?;
448        let d = self.data.get(state.index)?;
449        let value = value_fn(d);
450        let total: f32 = self.data.iter().map(|d| value_fn(d).max(0.)).sum();
451        let share = if total > 0. { value / total * 100. } else { 0. };
452        let name = self.name.clone().unwrap_or_default();
453
454        Some(
455            // Follow the cursor; the lifted slice marks the datum.
456            Tooltip::new(cursor, bounds.size)
457                .gap(px(8.))
458                .when_some(self.label.as_ref(), |this, label| this.title(label(d)))
459                .row(
460                    self.slice_color(d, cx),
461                    name,
462                    format!("{} ({:.1}%)", value, share),
463                )
464                .into_any_element(),
465        )
466    }
467}
468
469/// A resolved label position before overlap adjustment.
470struct LabelLayout {
471    /// Anchor on the ring edge (relative to center).
472    arc_x: f32,
473    arc_y: f32,
474    /// Centroid x at the label radius (relative to center).
475    label_x: f32,
476    /// Target/adjusted vertical position (relative to center).
477    y: f32,
478    text: SharedString,
479    line_color: Hsla,
480}
481
482/// Spread `items` vertically so that adjacent labels keep at least
483/// [`TEXT_HEIGHT`] apart, clamped within `[top, bottom]`.
484///
485/// Uses a two-direction relaxation: a top-down pass pushes crowded labels down,
486/// then a bottom-up pass (anchored at `bottom`) pushes them back up. This
487/// resolves cascading overlaps that a single-neighbor nudge cannot.
488fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
489    let n = items.len();
490    if n == 0 {
491        return;
492    }
493
494    // Sort by target position so neighbors in the slice are neighbors in y.
495    items.sort_by(|a, b| a.y.total_cmp(&b.y));
496
497    // Top-down: enforce the minimum gap by pushing labels down.
498    for i in 1..n {
499        let min_y = items[i - 1].y + TEXT_HEIGHT;
500        if items[i].y < min_y {
501            items[i].y = min_y;
502        }
503    }
504
505    // Bottom-up: clamp the bottom-most label, then pull overflowing labels up.
506    if items[n - 1].y > bottom {
507        items[n - 1].y = bottom;
508    }
509    for i in (0..n - 1).rev() {
510        let max_y = items[i + 1].y - TEXT_HEIGHT;
511        if items[i].y > max_y {
512            items[i].y = max_y;
513        }
514    }
515
516    // Keep the top-most label within bounds.
517    if items[0].y < top {
518        items[0].y = top;
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use gpui::size;
525
526    use super::*;
527
528    /// A chart left without an `outer_radius` lays its ring out at 40% of the
529    /// height. Slices and hit-testing have to use that radius: reading the
530    /// unset `outer_radius` field instead leaves every slice at zero, which
531    /// paints nothing and matches no cursor.
532    #[test]
533    fn test_pie_chart_slice_radius_falls_back_to_the_ring() {
534        let bounds = Bounds {
535            origin: point(px(0.), px(0.)),
536            size: size(px(200.), px(200.)),
537        };
538
539        let chart = PieChart::new(vec![1f32, 3.]).value(|d| *d);
540        let ring = chart.resolve_outer_radius(&bounds);
541        assert_eq!(ring, 80.);
542        assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), ring);
543
544        // An explicit radius, and a per-slice one, still win.
545        let chart = PieChart::new(vec![1f32, 3.])
546            .value(|d| *d)
547            .outer_radius(50.);
548        let ring = chart.resolve_outer_radius(&bounds);
549        assert_eq!(ring, 50.);
550        assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), 50.);
551
552        let chart = PieChart::new(vec![1f32, 3.])
553            .value(|d| *d)
554            .outer_radius_fn(|a| 10. + a.index as f32);
555        let ring = chart.resolve_outer_radius(&bounds);
556        let arcs = chart.arcs();
557        assert_eq!(chart.get_outer_radius(&arcs[0], ring), 10.);
558        assert_eq!(chart.get_outer_radius(&arcs[1], ring), 11.);
559    }
560}