1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, TextAlign,
5 Window, point, prelude::FluentBuilder, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::Zero;
10
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
40#[derive(IntoPlot)]
41pub struct PieChart<T: 'static> {
42 data: Vec<T>,
43 inner_radius: f32,
44 inner_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
45 outer_radius: f32,
46 outer_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
47 pad_angle: f32,
48 value: Option<Rc<dyn Fn(&T) -> f32>>,
49 color: Option<Rc<dyn Fn(&T) -> Hsla>>,
50 label: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
51 label_line_color: Option<Rc<dyn Fn(&T) -> Hsla + 'static>>,
52 label_color: Option<Hsla>,
53 label_gap: f32,
54 id: Option<ElementId>,
55 name: Option<SharedString>,
56 hover: Option<PieHover>,
57}
58
59impl<T> PieChart<T> {
60 pub fn new<I>(data: I) -> Self
61 where
62 I: IntoIterator<Item = T>,
63 {
64 Self {
65 data: data.into_iter().collect(),
66 inner_radius: 0.,
67 inner_radius_fn: None,
68 outer_radius: 0.,
69 outer_radius_fn: None,
70 pad_angle: 0.,
71 value: None,
72 color: None,
73 label: None,
74 label_line_color: None,
75 label_color: None,
76 label_gap: DEFAULT_LABEL_GAP,
77 id: None,
78 name: None,
79 hover: None,
80 }
81 }
82
83 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
89 self.id = Some(id.into());
90 self
91 }
92
93 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
95 self.name = Some(name.into());
96 self
97 }
98
99 pub fn inner_radius(mut self, inner_radius: f32) -> Self {
101 self.inner_radius = inner_radius;
102 self
103 }
104
105 pub fn inner_radius_fn(
107 mut self,
108 inner_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
109 ) -> Self {
110 self.inner_radius_fn = Some(Rc::new(inner_radius_fn));
111 self
112 }
113
114 fn get_inner_radius(&self, arc: &ArcData<T>) -> f32 {
115 if let Some(inner_radius_fn) = self.inner_radius_fn.as_ref() {
116 inner_radius_fn(arc)
117 } else {
118 self.inner_radius
119 }
120 }
121
122 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
124 self.outer_radius = outer_radius;
125 self
126 }
127
128 pub fn outer_radius_fn(
130 mut self,
131 outer_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
132 ) -> Self {
133 self.outer_radius_fn = Some(Rc::new(outer_radius_fn));
134 self
135 }
136
137 fn get_outer_radius(&self, arc: &ArcData<T>, default: f32) -> f32 {
141 if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
142 outer_radius_fn(arc)
143 } else {
144 default
145 }
146 }
147
148 pub fn pad_angle(mut self, pad_angle: f32) -> Self {
150 self.pad_angle = pad_angle;
151 self
152 }
153
154 pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
155 self.value = Some(Rc::new(value));
156 self
157 }
158
159 pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
161 where
162 H: Into<Hsla> + 'static,
163 {
164 self.color = Some(Rc::new(move |t| color(t).into()));
165 self
166 }
167
168 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
173 self.label = Some(Rc::new(label));
174 self
175 }
176
177 pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
179 self.label_line_color = Some(Rc::new(color));
180 self
181 }
182
183 pub fn label_color(mut self, color: Hsla) -> Self {
185 self.label_color = Some(color);
186 self
187 }
188
189 pub fn label_gap(mut self, gap: f32) -> Self {
192 self.label_gap = gap;
193 self
194 }
195
196 fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
199 if self.outer_radius.is_zero() {
200 bounds.size.height.as_f32() * 0.4
201 } else {
202 self.outer_radius
203 }
204 }
205
206 fn arcs(&self) -> Vec<ArcData<'_, T>> {
209 let Some(value_fn) = self.value.clone() else {
210 return vec![];
211 };
212 Pie::<T>::new()
213 .value(move |d| Some(value_fn(d)))
214 .pad_angle(self.pad_angle)
215 .arcs(&self.data)
216 }
217
218 fn slice_color(&self, datum: &T, cx: &App) -> Hsla {
220 match self.color.as_ref() {
221 Some(color_fn) => color_fn(datum),
222 None => cx.theme().chart_2,
223 }
224 }
225
226 fn slice_emphasis(&self, index: usize) -> (f32, f32) {
229 let Some(hover) = self.hover.as_ref() else {
230 return (0., 1.);
231 };
232 let lift = hover.lift.get(index).copied().unwrap_or(0.) * hover.focus;
233 (lift, 1. - HOVER_DIM * hover.focus * (1. - lift))
234 }
235}
236
237impl<T> Plot for PieChart<T> {
238 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
239 if self.value.is_none() {
240 return;
241 }
242
243 let outer_radius = self.resolve_outer_radius(&bounds);
244
245 let arc = Arc::new()
246 .inner_radius(self.inner_radius)
247 .outer_radius(outer_radius);
248 let arcs = self.arcs();
249
250 let caches = self
253 .id
254 .is_some()
255 .then(|| PathCaches::for_paint("slices", window, cx));
256 for (ix, a) in arcs.iter().enumerate() {
257 let inner_radius = self.get_inner_radius(a);
258 let (lift, opacity) = self.slice_emphasis(a.index);
260 let slice_radius = self.get_outer_radius(a, outer_radius) + HOVER_LIFT * lift;
261 let color = self.slice_color(a.data, cx).opacity(opacity);
262 match caches.as_ref() {
263 Some(caches) => caches.update(cx, |caches, _| {
264 arc.paint_cached(
265 a,
266 color,
267 Some(inner_radius),
268 Some(slice_radius),
269 &bounds,
270 caches.slot(ix),
271 window,
272 );
273 }),
274 None => arc.paint(
275 a,
276 color,
277 Some(inner_radius),
278 Some(slice_radius),
279 &bounds,
280 window,
281 ),
282 }
283 }
284
285 let Some(label_fn) = self.label.as_ref() else {
287 return;
288 };
289
290 let label_radius = outer_radius + self.label_gap;
291 let center_x = bounds.size.width.as_f32() / 2.;
292 let center_y = bounds.size.height.as_f32() / 2.;
293 let label_arc = Arc::new()
294 .inner_radius(label_radius)
295 .outer_radius(label_radius);
296
297 let label_color = self.label_color.unwrap_or(cx.theme().foreground);
298 let default_line_color = cx.theme().border;
299
300 let mut right: Vec<LabelLayout> = vec![];
304 let mut left: Vec<LabelLayout> = vec![];
305 for a in &arcs {
306 if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
308 continue;
309 }
310
311 let centroid = label_arc.centroid(a);
312 let (lift, _) = self.slice_emphasis(a.index);
316 let edge_radius = (outer_radius + HOVER_LIFT * lift).min(label_radius);
317 let edge = Arc::new()
318 .inner_radius(edge_radius)
319 .outer_radius(edge_radius)
320 .centroid(a);
321 let is_right = centroid.x > 0.;
322 let line_color = self
323 .label_line_color
324 .as_ref()
325 .map(|f| f(a.data))
326 .unwrap_or(default_line_color);
327
328 let layout = LabelLayout {
329 arc_x: edge.x,
330 arc_y: edge.y,
331 label_x: centroid.x,
332 y: centroid.y,
333 text: label_fn(a.data),
334 line_color,
335 };
336 if is_right { &mut right } else { &mut left }.push(layout);
337 }
338
339 let top = -center_y + TEXT_HEIGHT / 2.;
342 let bottom = center_y - TEXT_HEIGHT / 2.;
343 spread_labels(&mut right, top, bottom);
344 spread_labels(&mut left, top, bottom);
345
346 let mut labels = vec![];
348 for (side, items) in [(1., &right), (-1., &left)] {
349 for item in items {
350 let pts = [
353 point(item.arc_x + center_x, item.arc_y + center_y),
354 point(item.label_x + center_x, item.y + center_y),
355 point(side * label_radius + center_x, item.y + center_y),
356 ];
357 if let Some(p) = polygon(&pts, &bounds) {
358 window.paint_path(p, item.line_color);
359 }
360
361 let origin = point(
363 side * (label_radius + 4.) + center_x,
364 item.y - TEXT_SIZE / 2. + center_y,
365 );
366 let align = if side > 0. {
367 TextAlign::Left
368 } else {
369 TextAlign::Right
370 };
371 labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
372 }
373 }
374
375 PlotLabel::new(labels).paint(&bounds, window, cx);
376 }
377
378 fn id(&self) -> Option<ElementId> {
379 self.id.clone()
380 }
381
382 fn tooltip_state(
383 &self,
384 position: Point<Pixels>,
385 bounds: Bounds<Pixels>,
386 _cx: &App,
387 ) -> Option<TooltipState> {
388 let outer_radius = self.resolve_outer_radius(&bounds);
389 let arc = Arc::new()
390 .inner_radius(self.inner_radius)
391 .outer_radius(outer_radius);
392 let position = point(position.x.as_f32(), position.y.as_f32());
393
394 let index = self.arcs().into_iter().find_map(|a| {
395 arc.contains(
396 &a,
397 position,
398 Some(self.get_inner_radius(&a)),
399 Some(self.get_outer_radius(&a, outer_radius)),
400 &bounds,
401 )
402 .then_some(a.index)
403 })?;
404
405 Some(TooltipState::new(
406 index,
407 point(px(position.x), px(position.y)),
408 vec![],
409 ))
410 }
411
412 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
413 self.hover = hover.map(|hover| {
414 let policy = cx.theme().motion_tokens().spring_control;
419 let lift = (0..self.data.len())
420 .map(|ix| {
421 let lifted =
422 hover.is_hovered() && !hover.is_entering() && ix == hover.state().index;
423 spring(
424 ElementId::named_usize("pie-slice", ix),
425 if lifted { 1. } else { 0. },
426 policy,
427 window,
428 cx,
429 )
430 })
431 .collect();
432 PieHover {
433 lift,
434 focus: hover.focus(),
435 }
436 });
437 }
438
439 fn tooltip(
440 &self,
441 state: &TooltipState,
442 cursor: Point<Pixels>,
443 bounds: Bounds<Pixels>,
444 _window: &mut Window,
445 cx: &mut App,
446 ) -> Option<AnyElement> {
447 let value_fn = self.value.as_ref()?;
448 let d = self.data.get(state.index)?;
449 let value = value_fn(d);
450 let total: f32 = self.data.iter().map(|d| value_fn(d).max(0.)).sum();
451 let share = if total > 0. { value / total * 100. } else { 0. };
452 let name = self.name.clone().unwrap_or_default();
453
454 Some(
455 Tooltip::new(cursor, bounds.size)
457 .gap(px(8.))
458 .when_some(self.label.as_ref(), |this, label| this.title(label(d)))
459 .row(
460 self.slice_color(d, cx),
461 name,
462 format!("{} ({:.1}%)", value, share),
463 )
464 .into_any_element(),
465 )
466 }
467}
468
469struct LabelLayout {
471 arc_x: f32,
473 arc_y: f32,
474 label_x: f32,
476 y: f32,
478 text: SharedString,
479 line_color: Hsla,
480}
481
482fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
489 let n = items.len();
490 if n == 0 {
491 return;
492 }
493
494 items.sort_by(|a, b| a.y.total_cmp(&b.y));
496
497 for i in 1..n {
499 let min_y = items[i - 1].y + TEXT_HEIGHT;
500 if items[i].y < min_y {
501 items[i].y = min_y;
502 }
503 }
504
505 if items[n - 1].y > bottom {
507 items[n - 1].y = bottom;
508 }
509 for i in (0..n - 1).rev() {
510 let max_y = items[i + 1].y - TEXT_HEIGHT;
511 if items[i].y > max_y {
512 items[i].y = max_y;
513 }
514 }
515
516 if items[0].y < top {
518 items[0].y = top;
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use gpui::size;
525
526 use super::*;
527
528 #[test]
533 fn test_pie_chart_slice_radius_falls_back_to_the_ring() {
534 let bounds = Bounds {
535 origin: point(px(0.), px(0.)),
536 size: size(px(200.), px(200.)),
537 };
538
539 let chart = PieChart::new(vec![1f32, 3.]).value(|d| *d);
540 let ring = chart.resolve_outer_radius(&bounds);
541 assert_eq!(ring, 80.);
542 assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), ring);
543
544 let chart = PieChart::new(vec![1f32, 3.])
546 .value(|d| *d)
547 .outer_radius(50.);
548 let ring = chart.resolve_outer_radius(&bounds);
549 assert_eq!(ring, 50.);
550 assert_eq!(chart.get_outer_radius(&chart.arcs()[0], ring), 50.);
551
552 let chart = PieChart::new(vec![1f32, 3.])
553 .value(|d| *d)
554 .outer_radius_fn(|a| 10. + a.index as f32);
555 let ring = chart.resolve_outer_radius(&bounds);
556 let arcs = chart.arcs();
557 assert_eq!(chart.get_outer_radius(&arcs[0], ring), 10.);
558 assert_eq!(chart.get_outer_radius(&arcs[1], ring), 11.);
559 }
560}