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
22const DEFAULT_LABEL_GAP: f32 = 15.;
24
25const HOVER_LIFT: f32 = 6.;
27
28const HOVER_DIM: f32 = 0.35;
30
31struct PieHover {
33 lift: Vec<f32>,
36 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
96 self.id = id.into();
97 self
98 }
99
100 pub fn interactive(mut self, interactive: bool) -> Self {
109 self.interactive = interactive;
110 self
111 }
112
113 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
115 self.name = Some(name.into());
116 self
117 }
118
119 pub fn inner_radius(mut self, inner_radius: f32) -> Self {
121 self.inner_radius = inner_radius;
122 self
123 }
124
125 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 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
144 self.outer_radius = outer_radius;
145 self
146 }
147
148 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 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 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 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 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
193 self.label = Some(Rc::new(label));
194 self
195 }
196
197 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 pub fn label_color(mut self, color: Hsla) -> Self {
205 self.label_color = Some(color);
206 self
207 }
208
209 pub fn label_gap(mut self, gap: f32) -> Self {
212 self.label_gap = gap;
213 self
214 }
215
216 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 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 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 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 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 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 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 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 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 let mut right: Vec<LabelLayout> = vec![];
339 let mut left: Vec<LabelLayout> = vec![];
340 for a in &arcs {
341 if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
343 continue;
344 }
345
346 let centroid = label_arc.centroid(a);
347 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 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 let mut labels = vec![];
383 for (side, items) in [(1., &right), (-1., &left)] {
384 for item in items {
385 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 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 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 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
506struct LabelLayout {
508 arc_x: f32,
510 arc_y: f32,
511 label_x: f32,
513 y: f32,
515 text: SharedString,
516 line_color: Hsla,
517}
518
519fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
526 let n = items.len();
527 if n == 0 {
528 return;
529 }
530
531 items.sort_by(|a, b| a.y.total_cmp(&b.y));
533
534 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 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 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 #[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 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 #[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 let labelled = PieChart::new(vec![1f32]).label(|_| "Tech".into());
609 assert!(labelled.tooltip_name.is_none());
610 assert!(labelled.label.is_some());
611 }
612}