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
23const DEFAULT_LABEL_GAP: f32 = 15.;
25
26const HOVER_LIFT: f32 = 6.;
28
29const HOVER_DIM: f32 = 0.35;
31
32struct PieHover {
34 lift: Vec<f32>,
37 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
98 self.id = id.into();
99 self
100 }
101
102 pub fn interactive(mut self, interactive: bool) -> Self {
111 self.interactive = interactive;
112 self
113 }
114
115 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
117 self.name = Some(name.into());
118 self
119 }
120
121 pub fn inner_radius(mut self, inner_radius: f32) -> Self {
123 self.inner_radius = inner_radius;
124 self
125 }
126
127 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 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
146 self.outer_radius = outer_radius;
147 self
148 }
149
150 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 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 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 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 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
195 self.label = Some(Rc::new(label));
196 self
197 }
198
199 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 pub fn label_color(mut self, color: Hsla) -> Self {
207 self.label_color = Some(color);
208 self
209 }
210
211 pub fn label_gap(mut self, gap: f32) -> Self {
214 self.label_gap = gap;
215 self
216 }
217
218 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 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 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 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 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 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 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 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 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 let mut right: Vec<LabelLayout> = vec![];
336 let mut left: Vec<LabelLayout> = vec![];
337 for a in &arcs {
338 if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
340 continue;
341 }
342
343 let centroid = label_arc.centroid(a);
344 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 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 let mut labels = vec![];
380 for (side, items) in [(1., &right), (-1., &left)] {
381 for item in items {
382 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 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 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 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
503struct LabelLayout {
505 arc_x: f32,
507 arc_y: f32,
508 label_x: f32,
510 y: f32,
512 text: SharedString,
513 line_color: Hsla,
514}
515
516fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
523 let n = items.len();
524 if n == 0 {
525 return;
526 }
527
528 items.sort_by(|a, b| a.y.total_cmp(&b.y));
530
531 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 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 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 #[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 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 #[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 let labelled = PieChart::new(vec![1f32]).label(|_| "Tech".into());
606 assert!(labelled.tooltip_name.is_none());
607 assert!(labelled.label.is_some());
608 }
609}