Skip to main content

gpui_macos/chart/
pie_chart.rs

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