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(IntoPlot)]
45pub struct PieChart<T: 'static> {
46 data: Vec<T>,
47 inner_radius: f32,
48 inner_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
49 outer_radius: f32,
50 outer_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
51 pad_angle: f32,
52 value: Option<Rc<dyn Fn(&T) -> f32>>,
53 color: Option<Rc<dyn Fn(&T) -> Hsla>>,
54 label: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
55 label_line_color: Option<Rc<dyn Fn(&T) -> Hsla + 'static>>,
56 label_color: Option<Hsla>,
57 label_gap: f32,
58 tooltip_name: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
59 tooltip_value: Option<Rc<dyn Fn(&T, f32, f32) -> SharedString + 'static>>,
60 id: ElementId,
61 interactive: bool,
62 appear: ChartAppear,
63 name: Option<SharedString>,
64 hover: Option<PieHover>,
65}
66
67impl<T> PieChart<T> {
68 #[track_caller]
69 pub fn new<I>(data: I) -> Self
70 where
71 I: IntoIterator<Item = T>,
72 {
73 Self {
74 data: data.into_iter().collect(),
75 inner_radius: 0.,
76 inner_radius_fn: None,
77 outer_radius: 0.,
78 outer_radius_fn: None,
79 pad_angle: 0.,
80 value: None,
81 color: None,
82 label: None,
83 label_line_color: None,
84 label_color: None,
85 label_gap: DEFAULT_LABEL_GAP,
86 tooltip_name: None,
87 tooltip_value: None,
88 id: caller_id(),
89 interactive: true,
90 appear: ChartAppear::default(),
91 name: None,
92 hover: None,
93 }
94 }
95
96 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
103 self.id = id.into();
104 self
105 }
106
107 pub fn interactive(mut self, interactive: bool) -> Self {
115 self.interactive = interactive;
116 self
117 }
118
119 pub fn appear(mut self, appear: bool) -> Self {
126 self.appear.set_enabled(appear);
127 self
128 }
129
130 pub fn appear_key(mut self, key: impl Hash) -> Self {
135 self.appear.set_key(key);
136 self
137 }
138
139 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
141 self.name = Some(name.into());
142 self
143 }
144
145 pub fn inner_radius(mut self, inner_radius: f32) -> Self {
147 self.inner_radius = inner_radius;
148 self
149 }
150
151 pub fn inner_radius_fn(
153 mut self,
154 inner_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
155 ) -> Self {
156 self.inner_radius_fn = Some(Rc::new(inner_radius_fn));
157 self
158 }
159
160 fn get_inner_radius(&self, arc: &ArcData<T>) -> f32 {
161 if let Some(inner_radius_fn) = self.inner_radius_fn.as_ref() {
162 inner_radius_fn(arc)
163 } else {
164 self.inner_radius
165 }
166 }
167
168 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
170 self.outer_radius = outer_radius;
171 self
172 }
173
174 pub fn outer_radius_fn(
176 mut self,
177 outer_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
178 ) -> Self {
179 self.outer_radius_fn = Some(Rc::new(outer_radius_fn));
180 self
181 }
182
183 fn get_outer_radius(&self, arc: &ArcData<T>, default: f32) -> f32 {
187 if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
188 outer_radius_fn(arc)
189 } else {
190 default
191 }
192 }
193
194 pub fn pad_angle(mut self, pad_angle: f32) -> Self {
196 self.pad_angle = pad_angle;
197 self
198 }
199
200 pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
201 self.value = Some(Rc::new(value));
202 self
203 }
204
205 pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
207 where
208 H: Into<Hsla> + 'static,
209 {
210 self.color = Some(Rc::new(move |t| color(t).into()));
211 self
212 }
213
214 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
219 self.label = Some(Rc::new(label));
220 self
221 }
222
223 pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
225 self.label_line_color = Some(Rc::new(color));
226 self
227 }
228
229 pub fn label_color(mut self, color: Hsla) -> Self {
231 self.label_color = Some(color);
232 self
233 }
234
235 pub fn label_gap(mut self, gap: f32) -> Self {
238 self.label_gap = gap;
239 self
240 }
241
242 pub fn tooltip_name(mut self, name: impl Fn(&T) -> SharedString + 'static) -> Self {
250 self.tooltip_name = Some(Rc::new(name));
251 self
252 }
253
254 pub fn tooltip_value(mut self, value: impl Fn(&T, f32, f32) -> SharedString + 'static) -> Self {
264 self.tooltip_value = Some(Rc::new(value));
265 self
266 }
267
268 fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
271 if self.outer_radius.is_zero() {
272 bounds.size.height.as_f32() * 0.4
273 } else {
274 self.outer_radius
275 }
276 }
277
278 fn arcs(&self) -> Vec<ArcData<'_, T>> {
281 let Some(value_fn) = self.value.clone() else {
282 return vec![];
283 };
284 Pie::<T>::new()
285 .value(move |d| Some(value_fn(d)))
286 .pad_angle(self.pad_angle)
287 .arcs(&self.data)
288 }
289
290 fn slice_color(&self, datum: &T, cx: &App) -> Hsla {
292 match self.color.as_ref() {
293 Some(color_fn) => color_fn(datum),
294 None => cx.theme().chart_2,
295 }
296 }
297
298 fn slice_emphasis(&self, index: usize) -> (f32, f32) {
301 let Some(hover) = self.hover.as_ref() else {
302 return (0., 1.);
303 };
304 let lift = hover.lift.get(index).copied().unwrap_or(0.) * hover.focus;
305 (lift, 1. - HOVER_DIM * hover.focus * (1. - lift))
306 }
307}
308
309impl<T> Plot for PieChart<T> {
310 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
311 if self.value.is_none() {
312 return;
313 }
314
315 let outer_radius = self.resolve_outer_radius(&bounds);
316 let arcs = self.arcs();
317
318 let appear = self.appear.get().progress();
322 let swept;
323 let slices = if appear < 1. {
324 let mut arcs = self.arcs();
325 let start = arcs.first().map_or(0., |a| a.start_angle);
326 for a in &mut arcs {
327 a.start_angle = start + (a.start_angle - start) * appear;
328 a.end_angle = start + (a.end_angle - start) * appear;
329 }
330 swept = arcs;
331 &swept
332 } else {
333 &arcs
334 };
335 let caches = (appear >= 1.).then(|| PathCaches::for_paint("slices", window, cx));
336 for (ix, a) in slices.iter().enumerate() {
337 let inner_radius = self.get_inner_radius(a);
338 let (lift, opacity) = self.slice_emphasis(a.index);
340 let slice_radius = self.get_outer_radius(a, outer_radius) + HOVER_LIFT * lift;
341 let color = self.slice_color(a.data, cx).opacity(opacity);
342 let arc = Arc::new()
343 .inner_radius(inner_radius)
344 .outer_radius(slice_radius);
345 match caches.as_ref() {
346 Some(caches) => caches.update(cx, |caches, _| {
347 arc.paint_cached(a, color, &bounds, caches.slot(ix), window);
348 }),
349 None => arc.paint(a, color, &bounds, window),
350 }
351 }
352
353 let Some(label_fn) = self.label.as_ref() else {
355 return;
356 };
357
358 let label_radius = outer_radius + self.label_gap;
359 let center_x = bounds.size.width.as_f32() / 2.;
360 let center_y = bounds.size.height.as_f32() / 2.;
361 let label_arc = Arc::new()
362 .inner_radius(label_radius)
363 .outer_radius(label_radius);
364
365 let label_opacity = ((appear - LABEL_APPEAR_START) / (1. - LABEL_APPEAR_START)).max(0.);
368 if label_opacity <= 0. {
369 return;
370 }
371 let label_color = self
372 .label_color
373 .unwrap_or(cx.theme().foreground)
374 .opacity(label_opacity);
375 let default_line_color = cx.theme().border;
376
377 let mut right: Vec<LabelLayout> = vec![];
381 let mut left: Vec<LabelLayout> = vec![];
382 for a in &arcs {
383 if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
385 continue;
386 }
387
388 let centroid = label_arc.centroid(a);
389 let (lift, _) = self.slice_emphasis(a.index);
393 let edge_radius = (outer_radius + HOVER_LIFT * lift).min(label_radius);
394 let edge = Arc::new()
395 .inner_radius(edge_radius)
396 .outer_radius(edge_radius)
397 .centroid(a);
398 let is_right = centroid.x > 0.;
399 let line_color = self
400 .label_line_color
401 .as_ref()
402 .map(|f| f(a.data))
403 .unwrap_or(default_line_color)
404 .opacity(label_opacity);
405
406 let layout = LabelLayout {
407 arc_x: edge.x,
408 arc_y: edge.y,
409 label_x: centroid.x,
410 y: centroid.y,
411 text: label_fn(a.data),
412 line_color,
413 };
414 if is_right { &mut right } else { &mut left }.push(layout);
415 }
416
417 let top = -center_y + TEXT_HEIGHT / 2.;
420 let bottom = center_y - TEXT_HEIGHT / 2.;
421 spread_labels(&mut right, top, bottom);
422 spread_labels(&mut left, top, bottom);
423
424 let mut labels = vec![];
426 for (side, items) in [(1., &right), (-1., &left)] {
427 for item in items {
428 let pts = [
431 point(item.arc_x + center_x, item.arc_y + center_y),
432 point(item.label_x + center_x, item.y + center_y),
433 point(side * label_radius + center_x, item.y + center_y),
434 ];
435 if let Some(p) = polygon(&pts, &bounds) {
436 window.paint_path(p, item.line_color);
437 }
438
439 let origin = point(
441 side * (label_radius + 4.) + center_x,
442 item.y - TEXT_SIZE / 2. + center_y,
443 );
444 let align = if side > 0. {
445 TextAlign::Left
446 } else {
447 TextAlign::Right
448 };
449 labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
450 }
451 }
452
453 PlotLabel::new(labels).paint(&bounds, window, cx);
454 }
455
456 fn id(&self) -> Option<ElementId> {
457 Some(self.id.clone())
458 }
459
460 fn interactive(&self) -> bool {
461 self.interactive
462 }
463
464 fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) {
465 self.appear.update(appear);
466 }
467
468 fn appear_generation(&self) -> Option<u64> {
469 self.appear.generation()
470 }
471
472 fn tooltip_state(
473 &self,
474 position: Point<Pixels>,
475 bounds: Bounds<Pixels>,
476 _cx: &App,
477 ) -> Option<TooltipState> {
478 let outer_radius = self.resolve_outer_radius(&bounds);
479 let position = point(position.x.as_f32(), position.y.as_f32());
480
481 let index = self.arcs().into_iter().find_map(|a| {
482 Arc::new()
483 .inner_radius(self.get_inner_radius(&a))
484 .outer_radius(self.get_outer_radius(&a, outer_radius))
485 .contains(&a, position, &bounds)
486 .then_some(a.index)
487 })?;
488
489 Some(TooltipState::new(
490 index,
491 point(px(position.x), px(position.y)),
492 vec![],
493 ))
494 }
495
496 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
497 self.hover = hover.map(|hover| {
498 let policy = cx.theme().motion_tokens().spring_control;
503 let lift = (0..self.data.len())
504 .map(|ix| {
505 let lifted =
506 hover.is_hovered() && !hover.is_entering() && ix == hover.state().index;
507 spring(
508 ElementId::named_usize("pie-slice", ix),
509 if lifted { 1. } else { 0. },
510 policy,
511 window,
512 cx,
513 )
514 })
515 .collect();
516 PieHover {
517 lift,
518 focus: hover.progress(),
519 }
520 });
521 }
522
523 fn tooltip(
524 &self,
525 state: &TooltipState,
526 cursor: Point<Pixels>,
527 bounds: Bounds<Pixels>,
528 _window: &mut Window,
529 cx: &mut App,
530 ) -> Option<AnyElement> {
531 let value_fn = self.value.as_ref()?;
532 let d = self.data.get(state.index)?;
533 let value = value_fn(d);
534 let total: f32 = self.data.iter().map(|d| value_fn(d).max(0.)).sum();
535 let share = if total > 0. { value / total * 100. } else { 0. };
536 let name = match self.tooltip_name.as_ref() {
537 Some(tooltip_name) => tooltip_name(d),
538 None => self.name.clone().unwrap_or_default(),
539 };
540
541 Some(
542 Tooltip::new(cursor, bounds.size)
547 .gap(px(8.))
548 .row(
549 self.slice_color(d, cx),
550 name,
551 match self.tooltip_value.as_ref() {
552 Some(tooltip_value) => tooltip_value(d, value, share),
553 None => format!("{value} ({share:.1}%)").into(),
554 },
555 )
556 .into_any_element(),
557 )
558 }
559}
560
561struct LabelLayout {
563 arc_x: f32,
565 arc_y: f32,
566 label_x: f32,
568 y: f32,
570 text: SharedString,
571 line_color: Hsla,
572}
573
574fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
581 let n = items.len();
582 if n == 0 {
583 return;
584 }
585
586 items.sort_by(|a, b| a.y.total_cmp(&b.y));
588
589 for i in 1..n {
591 let min_y = items[i - 1].y + TEXT_HEIGHT;
592 if items[i].y < min_y {
593 items[i].y = min_y;
594 }
595 }
596
597 if items[n - 1].y > bottom {
599 items[n - 1].y = bottom;
600 }
601 for i in (0..n - 1).rev() {
602 let max_y = items[i + 1].y - TEXT_HEIGHT;
603 if items[i].y > max_y {
604 items[i].y = max_y;
605 }
606 }
607
608 if items[0].y < top {
610 items[0].y = top;
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use gpui::size;
617
618 use super::*;
619
620 #[test]
625 fn test_pie_chart_slice_radius_falls_back_to_the_ring() {
626 let bounds = Bounds {
627 origin: point(px(0.), px(0.)),
628 size: size(px(200.), px(200.)),
629 };
630
631 let chart = PieChart::new(vec![1f32, 3.]).value(|d| *d);
632 let ring = chart.resolve_outer_radius(&bounds);
633 assert_eq!(ring, 80.);
634 assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), ring);
635
636 let chart = PieChart::new(vec![1f32, 3.])
638 .value(|d| *d)
639 .outer_radius(50.);
640 let ring = chart.resolve_outer_radius(&bounds);
641 assert_eq!(ring, 50.);
642 assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), 50.);
643
644 let chart = PieChart::new(vec![1f32, 3.])
645 .value(|d| *d)
646 .outer_radius_fn(|a| 10. + a.index as f32);
647 let ring = chart.resolve_outer_radius(&bounds);
648 let arcs = chart.arcs();
649 assert_eq!(chart.get_outer_radius(&arcs[0], ring), 10.);
650 assert_eq!(chart.get_outer_radius(&arcs[1], ring), 11.);
651 }
652
653 #[test]
657 fn test_tooltip_name_does_not_turn_on_leader_lines() {
658 let titled = PieChart::new(vec![1f32]).tooltip_name(|_| "Tech".into());
659 assert!(titled.tooltip_name.is_some());
660 assert!(titled.label.is_none());
661
662 let labelled = PieChart::new(vec![1f32]).label(|_| "Tech".into());
664 assert!(labelled.tooltip_name.is_none());
665 assert!(labelled.label.is_some());
666 }
667}