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>) -> f32 {
138 if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
139 outer_radius_fn(arc)
140 } else {
141 self.outer_radius
142 }
143 }
144
145 pub fn pad_angle(mut self, pad_angle: f32) -> Self {
147 self.pad_angle = pad_angle;
148 self
149 }
150
151 pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
152 self.value = Some(Rc::new(value));
153 self
154 }
155
156 pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
158 where
159 H: Into<Hsla> + 'static,
160 {
161 self.color = Some(Rc::new(move |t| color(t).into()));
162 self
163 }
164
165 pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
170 self.label = Some(Rc::new(label));
171 self
172 }
173
174 pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
176 self.label_line_color = Some(Rc::new(color));
177 self
178 }
179
180 pub fn label_color(mut self, color: Hsla) -> Self {
182 self.label_color = Some(color);
183 self
184 }
185
186 pub fn label_gap(mut self, gap: f32) -> Self {
189 self.label_gap = gap;
190 self
191 }
192
193 fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
196 if self.outer_radius.is_zero() {
197 bounds.size.height.as_f32() * 0.4
198 } else {
199 self.outer_radius
200 }
201 }
202
203 fn arcs(&self) -> Vec<ArcData<'_, T>> {
206 let Some(value_fn) = self.value.clone() else {
207 return vec![];
208 };
209 Pie::<T>::new()
210 .value(move |d| Some(value_fn(d)))
211 .pad_angle(self.pad_angle)
212 .arcs(&self.data)
213 }
214
215 fn slice_color(&self, datum: &T, cx: &App) -> Hsla {
217 match self.color.as_ref() {
218 Some(color_fn) => color_fn(datum),
219 None => cx.theme().chart_2,
220 }
221 }
222
223 fn slice_emphasis(&self, index: usize) -> (f32, f32) {
226 let Some(hover) = self.hover.as_ref() else {
227 return (0., 1.);
228 };
229 let lift = hover.lift.get(index).copied().unwrap_or(0.) * hover.focus;
230 (lift, 1. - HOVER_DIM * hover.focus * (1. - lift))
231 }
232}
233
234impl<T> Plot for PieChart<T> {
235 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
236 if self.value.is_none() {
237 return;
238 }
239
240 let outer_radius = self.resolve_outer_radius(&bounds);
241
242 let arc = Arc::new()
243 .inner_radius(self.inner_radius)
244 .outer_radius(outer_radius);
245 let arcs = self.arcs();
246
247 let caches = self
250 .id
251 .is_some()
252 .then(|| PathCaches::for_paint("slices", window, cx));
253 for (ix, a) in arcs.iter().enumerate() {
254 let inner_radius = self.get_inner_radius(a);
255 let (lift, opacity) = self.slice_emphasis(a.index);
257 let outer_radius = self.get_outer_radius(a) + HOVER_LIFT * lift;
258 let color = self.slice_color(a.data, cx).opacity(opacity);
259 match caches.as_ref() {
260 Some(caches) => caches.update(cx, |caches, _| {
261 arc.paint_cached(
262 a,
263 color,
264 Some(inner_radius),
265 Some(outer_radius),
266 &bounds,
267 caches.slot(ix),
268 window,
269 );
270 }),
271 None => arc.paint(
272 a,
273 color,
274 Some(inner_radius),
275 Some(outer_radius),
276 &bounds,
277 window,
278 ),
279 }
280 }
281
282 let Some(label_fn) = self.label.as_ref() else {
284 return;
285 };
286
287 let label_radius = outer_radius + self.label_gap;
288 let center_x = bounds.size.width.as_f32() / 2.;
289 let center_y = bounds.size.height.as_f32() / 2.;
290 let label_arc = Arc::new()
291 .inner_radius(label_radius)
292 .outer_radius(label_radius);
293 let edge_arc = Arc::new()
294 .inner_radius(outer_radius)
295 .outer_radius(outer_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 edge = edge_arc.centroid(a);
313 let is_right = centroid.x > 0.;
314 let line_color = self
315 .label_line_color
316 .as_ref()
317 .map(|f| f(a.data))
318 .unwrap_or(default_line_color);
319
320 let layout = LabelLayout {
321 arc_x: edge.x,
322 arc_y: edge.y,
323 label_x: centroid.x,
324 y: centroid.y,
325 text: label_fn(a.data),
326 line_color,
327 };
328 if is_right { &mut right } else { &mut left }.push(layout);
329 }
330
331 let top = -center_y + TEXT_HEIGHT / 2.;
334 let bottom = center_y - TEXT_HEIGHT / 2.;
335 spread_labels(&mut right, top, bottom);
336 spread_labels(&mut left, top, bottom);
337
338 let mut labels = vec![];
340 for (side, items) in [(1., &right), (-1., &left)] {
341 for item in items {
342 let pts = [
345 point(item.arc_x + center_x, item.arc_y + center_y),
346 point(item.label_x + center_x, item.y + center_y),
347 point(side * label_radius + center_x, item.y + center_y),
348 ];
349 if let Some(p) = polygon(&pts, &bounds) {
350 window.paint_path(p, item.line_color);
351 }
352
353 let origin = point(
355 side * (label_radius + 4.) + center_x,
356 item.y - TEXT_SIZE / 2. + center_y,
357 );
358 let align = if side > 0. {
359 TextAlign::Left
360 } else {
361 TextAlign::Right
362 };
363 labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
364 }
365 }
366
367 PlotLabel::new(labels).paint(&bounds, window, cx);
368 }
369
370 fn id(&self) -> Option<ElementId> {
371 self.id.clone()
372 }
373
374 fn tooltip_state(
375 &self,
376 position: Point<Pixels>,
377 bounds: Bounds<Pixels>,
378 _cx: &App,
379 ) -> Option<TooltipState> {
380 let outer_radius = self.resolve_outer_radius(&bounds);
381 let arc = Arc::new()
382 .inner_radius(self.inner_radius)
383 .outer_radius(outer_radius);
384 let position = point(position.x.as_f32(), position.y.as_f32());
385
386 let index = self.arcs().into_iter().find_map(|a| {
387 arc.contains(
388 &a,
389 position,
390 Some(self.get_inner_radius(&a)),
391 Some(self.get_outer_radius(&a)),
392 &bounds,
393 )
394 .then_some(a.index)
395 })?;
396
397 Some(TooltipState::new(
398 index,
399 point(px(position.x), px(position.y)),
400 vec![],
401 ))
402 }
403
404 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
405 self.hover = hover.map(|hover| {
406 let policy = cx.theme().motion_tokens().spring_control;
411 let lift = (0..self.data.len())
412 .map(|ix| {
413 let lifted =
414 hover.is_hovered() && !hover.is_entering() && ix == hover.state().index;
415 spring(
416 ElementId::named_usize("pie-slice", ix),
417 if lifted { 1. } else { 0. },
418 policy,
419 window,
420 cx,
421 )
422 })
423 .collect();
424 PieHover {
425 lift,
426 focus: hover.focus(),
427 }
428 });
429 }
430
431 fn tooltip(
432 &self,
433 state: &TooltipState,
434 cursor: Point<Pixels>,
435 bounds: Bounds<Pixels>,
436 _window: &mut Window,
437 cx: &mut App,
438 ) -> Option<AnyElement> {
439 let value_fn = self.value.as_ref()?;
440 let d = self.data.get(state.index)?;
441 let value = value_fn(d);
442 let total: f32 = self.data.iter().map(|d| value_fn(d).max(0.)).sum();
443 let share = if total > 0. { value / total * 100. } else { 0. };
444 let name = self.name.clone().unwrap_or_default();
445
446 Some(
447 Tooltip::new(cursor, bounds.size)
449 .gap(px(8.))
450 .when_some(self.label.as_ref(), |this, label| this.title(label(d)))
451 .row(
452 self.slice_color(d, cx),
453 name,
454 format!("{} ({:.1}%)", value, share),
455 )
456 .into_any_element(),
457 )
458 }
459}
460
461struct LabelLayout {
463 arc_x: f32,
465 arc_y: f32,
466 label_x: f32,
468 y: f32,
470 text: SharedString,
471 line_color: Hsla,
472}
473
474fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
481 let n = items.len();
482 if n == 0 {
483 return;
484 }
485
486 items.sort_by(|a, b| a.y.total_cmp(&b.y));
488
489 for i in 1..n {
491 let min_y = items[i - 1].y + TEXT_HEIGHT;
492 if items[i].y < min_y {
493 items[i].y = min_y;
494 }
495 }
496
497 if items[n - 1].y > bottom {
499 items[n - 1].y = bottom;
500 }
501 for i in (0..n - 1).rev() {
502 let max_y = items[i + 1].y - TEXT_HEIGHT;
503 if items[i].y > max_y {
504 items[i].y = max_y;
505 }
506 }
507
508 if items[0].y < top {
510 items[0].y = top;
511 }
512}