Skip to main content

gpui_component/chart/
pie_chart.rs

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