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