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
23const DEFAULT_LABEL_GAP: f32 = 15.;
25
26const HOVER_LIFT: f32 = 6.;
28
29const HOVER_DIM: f32 = 0.35;
31
32const LABEL_APPEAR_START: f32 = 0.7;
34
35struct PieHover {
37 lift: Vec<f32>,
40 focus: f32,
42}
43
44#[derive(Clone, Copy)]
47enum InnerRadius {
48 Pixels(f32),
49 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
112 self.id = id.into();
113 self
114 }
115
116 pub fn interactive(mut self, interactive: bool) -> Self {
124 self.interactive = interactive;
125 self
126 }
127
128 pub fn appear(mut self, appear: bool) -> Self {
135 self.appear.set_enabled(appear);
136 self
137 }
138
139 pub fn appear_key(mut self, key: impl Hash) -> Self {
144 self.appear.set_key(key);
145 self
146 }
147
148 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
150 self.name = Some(name.into());
151 self
152 }
153
154 pub fn inner_radius(mut self, inner_radius: f32) -> Self {
159 self.inner_radius = InnerRadius::Pixels(inner_radius);
160 self
161 }
162
163 pub fn inner_radius_ratio(mut self, ratio: f32) -> Self {
173 self.inner_radius = InnerRadius::Ratio(ratio);
174 self
175 }
176
177 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 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 InnerRadius::Ratio(ratio) if ratio.is_nan() => 0.,
196 InnerRadius::Ratio(ratio) => ring * ratio.clamp(0., 1.),
197 }
198 }
199
200 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
202 self.outer_radius = outer_radius;
203 self
204 }
205
206 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 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 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 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 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
251 self.label = Some(Rc::new(label));
252 self
253 }
254
255 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 pub fn label_color(mut self, color: Hsla) -> Self {
263 self.label_color = Some(color);
264 self
265 }
266
267 pub fn label_gap(mut self, gap: f32) -> Self {
270 self.label_gap = gap;
271 self
272 }
273
274 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 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 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 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 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 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 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 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 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 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 let mut right: Vec<LabelLayout> = vec![];
413 let mut left: Vec<LabelLayout> = vec![];
414 for a in &arcs {
415 if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
417 continue;
418 }
419
420 let centroid = label_arc.centroid(a);
421 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 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 let mut labels = vec![];
458 for (side, items) in [(1., &right), (-1., &left)] {
459 for item in items {
460 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 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 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 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
593struct LabelLayout {
595 arc_x: f32,
597 arc_y: f32,
598 label_x: f32,
600 y: f32,
602 text: SharedString,
603 line_color: Hsla,
604}
605
606fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
613 let n = items.len();
614 if n == 0 {
615 return;
616 }
617
618 items.sort_by(|a, b| a.y.total_cmp(&b.y));
620
621 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 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 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 #[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 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 #[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 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 #[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 #[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 let labelled = PieChart::new(vec![1f32]).label(|_| "Tech".into());
757 assert!(labelled.tooltip_name.is_none());
758 assert!(labelled.label.is_some());
759 }
760}