1use std::{hash::Hash, ops::RangeInclusive, rc::Rc};
2
3use gpui::{
4 AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop,
5 Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px,
6};
7use gpui_component_macros::IntoPlot;
8use num_traits::{Num, ToPrimitive};
9
10use crate::{
11 ActiveTheme,
12 plot::{
13 AXIS_GAP, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel,
14 label::{TEXT_GAP, TEXT_SIZE, Text, measure_text_width},
15 scale::{Scale, ScaleBand, ScaleLinear, Sealed},
16 shape::{Bar, BarAlignment},
17 tooltip::{CrossLine, Tooltip, TooltipState},
18 },
19};
20
21use super::build_band_labels;
22
23const VALUE_AXIS_GAP: f32 = 32.;
29
30#[derive(IntoPlot)]
31pub struct BarChart<T, B, V>
32where
33 T: 'static,
34 B: Eq + Hash + Into<SharedString> + 'static,
35 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
36{
37 data: Vec<T>,
38 band: Option<Rc<dyn Fn(&T) -> B>>,
39 value: Option<Rc<dyn Fn(&T) -> V>>,
40 fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
41 #[allow(clippy::type_complexity)]
42 fill_gradient:
43 Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
44 tick_margin: usize,
45 label: Option<Rc<dyn Fn(&T) -> SharedString>>,
46 label_axis: bool,
47 value_axis: bool,
48 value_tick_count: usize,
49 grid: bool,
50 alignment: BarAlignment,
51 corner_radii: Corners<Pixels>,
52 id: Option<ElementId>,
53 name: Option<SharedString>,
54}
55
56impl<T, B, V> BarChart<T, B, V>
57where
58 B: Eq + Hash + Into<SharedString> + 'static,
59 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
60{
61 pub fn new<I>(data: I) -> Self
62 where
63 I: IntoIterator<Item = T>,
64 {
65 Self {
66 data: data.into_iter().collect(),
67 band: None,
68 value: None,
69 fill: None,
70 fill_gradient: None,
71 tick_margin: 1,
72 label: None,
73 label_axis: true,
74 value_axis: false,
75 value_tick_count: 4,
76 grid: true,
77 alignment: BarAlignment::default(),
78 corner_radii: Corners::all(px(0.)),
79 id: None,
80 name: None,
81 }
82 }
83
84 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
90 self.id = Some(id.into());
91 self
92 }
93
94 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
96 self.name = Some(name.into());
97 self
98 }
99
100 pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
102 self.band = Some(Rc::new(band));
103 self
104 }
105
106 pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
108 self.value = Some(Rc::new(value));
109 self
110 }
111
112 pub fn fill<Bg>(
132 mut self,
133 fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
134 ) -> Self
135 where
136 Bg: Into<Background> + 'static,
137 {
138 self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
139 fill(t, bar_bounds, chart_bounds, alignment).into()
140 }));
141 self.fill_gradient = None;
142 self
143 }
144
145 pub fn fill_gradient(
181 mut self,
182 fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
183 ) -> Self {
184 self.fill_gradient = Some(Rc::new(fill));
185 self.fill = None;
186 self
187 }
188
189 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
190 self.tick_margin = tick_margin;
191 self
192 }
193
194 pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
195 where
196 S: Into<SharedString> + 'static,
197 {
198 self.label = Some(Rc::new(move |t| label(t).into()));
199 self
200 }
201
202 pub fn label_axis(mut self, label_axis: bool) -> Self {
206 self.label_axis = label_axis;
207 self
208 }
209
210 pub fn value_axis(mut self, value_axis: bool) -> Self {
217 self.value_axis = value_axis;
218 self
219 }
220
221 pub fn value_tick_count(mut self, value_tick_count: usize) -> Self {
229 self.value_tick_count = value_tick_count.max(1);
230 self
231 }
232
233 pub fn grid(mut self, grid: bool) -> Self {
234 self.grid = grid;
235 self
236 }
237
238 pub fn alignment(mut self, alignment: BarAlignment) -> Self {
242 self.alignment = alignment;
243 self
244 }
245
246 pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
251 self.corner_radii = corner_radii.into();
252 self
253 }
254
255 fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
258 let band_fn = self.band.as_ref()?;
259 let band_extent = if self.alignment.is_horizontal() {
260 bounds.size.height.as_f32()
261 } else {
262 bounds.size.width.as_f32()
263 };
264 let gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
267 Some(
268 ScaleBand::new(
269 self.data.iter().map(|v| band_fn(v)).collect(),
270 vec![0., (band_extent - gap).max(0.)],
271 )
272 .padding_inner(0.4)
273 .padding_outer(0.2),
274 )
275 }
276
277 fn band_offset(&self) -> f32 {
284 if self.value_axis && !self.alignment.is_horizontal() {
285 VALUE_AXIS_GAP
286 } else {
287 0.
288 }
289 }
290
291 fn horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
295 let Some(band_fn) = self.band.as_ref() else {
296 return (0., 0.);
297 };
298 let font_size = px(TEXT_SIZE);
299 let band_gap = if self.label_axis {
300 self.data
301 .iter()
302 .map(|v| {
303 let s: SharedString = band_fn(v).into();
304 measure_text_width(&s, font_size, window)
305 })
306 .fold(0f32, f32::max)
307 + TEXT_GAP * 2.
308 } else {
309 0.
310 };
311 let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
312 self.data
313 .iter()
314 .map(|v| measure_text_width(&label_fn(v), font_size, window))
315 .fold(0f32, f32::max)
316 + TEXT_GAP * 2.
317 } else {
318 TEXT_GAP * 4.
319 };
320 (band_gap, value_end_gap)
321 }
322}
323
324impl<T, B, V> Plot for BarChart<T, B, V>
325where
326 B: Eq + Hash + Into<SharedString> + 'static,
327 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
328{
329 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
330 let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
331 return;
332 };
333
334 let total_width = bounds.size.width.as_f32();
335 let total_height = bounds.size.height.as_f32();
336 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
337 let alignment = self.alignment;
338 let is_horizontal = alignment.is_horizontal();
339
340 let Some(band_scale) = self.band_scale(bounds) else {
343 return;
344 };
345 let band_width = band_scale.band_width();
346
347 let value_dim = if is_horizontal {
348 total_width
349 } else {
350 total_height
351 };
352 let (band_gap, value_end_gap) = if is_horizontal {
358 self.horizontal_gaps(window)
359 } else {
360 (axis_gap, 10.)
361 };
362 let (range, baseline) = match alignment {
363 BarAlignment::Bottom => {
364 let baseline = value_dim - axis_gap;
365 (vec![baseline, 10.], baseline)
366 }
367 BarAlignment::Top => {
368 let baseline = axis_gap;
369 (vec![baseline, value_dim - 10.], baseline)
370 }
371 BarAlignment::Left => {
372 let baseline = band_gap;
373 (vec![baseline, value_dim - value_end_gap], baseline)
374 }
375 BarAlignment::Right => {
376 let baseline = value_dim - band_gap;
377 (vec![baseline, value_end_gap], baseline)
378 }
379 };
380 let value_scale = ScaleLinear::new(
381 self.data
382 .iter()
383 .map(|v| value_fn(v))
384 .chain(Some(V::zero()))
385 .collect(),
386 range,
387 );
388
389 let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
393 let band_offset = self.band_offset();
394
395 let value_axis_gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
399 let plot_bounds = if is_horizontal {
400 Bounds {
401 origin: bounds.origin,
402 size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
403 }
404 } else {
405 Bounds {
406 origin: bounds.origin + point(px(value_axis_gap), px(0.)),
407 size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
408 }
409 };
410
411 let (domain_lo, domain_hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
414 let f = value_fn(v).to_f32().unwrap_or(0.);
415 (lo.min(f), hi.max(f))
416 });
417
418 let mut axis = PlotAxis::new().stroke(cx.theme().border);
420 if self.label_axis {
421 match alignment {
422 BarAlignment::Bottom | BarAlignment::Top => {
423 axis = axis.x(zero_pixel);
424
425 let labels = self
430 .data
431 .iter()
432 .enumerate()
433 .filter(|(i, _)| (i + 1) % self.tick_margin == 0)
434 .filter_map(|(_, d)| {
435 let band_x = band_scale.tick(&band_fn(d))?;
436 let value = value_fn(d).to_f32().unwrap_or(0.);
437 let label_y = if label_below_zero_line(value, alignment) {
438 zero_pixel + TEXT_GAP
439 } else {
440 zero_pixel - TEXT_GAP - TEXT_SIZE
441 };
442
443 Some(
444 Text::new(
445 band_fn(d).into(),
446 point(px(band_x + band_offset + band_width / 2.), px(label_y)),
447 cx.theme().muted_foreground,
448 )
449 .align(TextAlign::Center),
450 )
451 })
452 .collect();
453 PlotLabel::new(labels).paint(&bounds, window, cx);
454 }
455 BarAlignment::Left | BarAlignment::Right => {
456 let labels = build_band_labels(
457 &self.data,
458 band_fn.as_ref(),
459 &band_scale,
460 band_width,
461 self.tick_margin,
462 cx.theme().muted_foreground,
463 );
464 let (side, align) = if matches!(alignment, BarAlignment::Left) {
465 (AxisLabelSide::Start, TextAlign::Right)
466 } else {
467 (AxisLabelSide::End, TextAlign::Left)
468 };
469 axis = axis
470 .y(zero_pixel)
471 .y_label_side(side)
472 .y_label(labels.into_iter().map(|t| t.align(align)));
473 }
474 }
475 }
476 axis.paint(&plot_bounds, window, cx);
477
478 let far = match alignment {
480 BarAlignment::Bottom => 10.,
481 BarAlignment::Top => value_dim - 10.,
482 BarAlignment::Left => value_dim - value_end_gap,
483 BarAlignment::Right => value_end_gap,
484 };
485
486 let steps = self.value_tick_count;
487 let value_ticks = value_tick_positions(far, baseline, steps);
488
489 if self.grid {
491 let grid = Grid::new()
492 .stroke(cx.theme().border)
493 .dash_array(&[px(4.), px(2.)]);
494 let lines = value_ticks[..steps].to_vec();
495 let grid = if is_horizontal {
496 grid.x(lines)
497 } else {
498 grid.y(lines)
499 };
500 grid.paint(&plot_bounds, window);
501 }
502
503 if self.value_axis {
504 let labels = value_ticks.iter().enumerate().map(|(i, &tick)| {
507 let value = domain_hi - (domain_hi - domain_lo) * i as f32 / steps as f32;
508 AxisText::new(format_tick(value), px(tick), cx.theme().muted_foreground)
509 });
510
511 let value_axis = if is_horizontal {
514 PlotAxis::new()
515 .x_axis(false)
516 .x(px(total_height - VALUE_AXIS_GAP))
517 .x_label(labels.map(|t| t.align(TextAlign::Center)))
518 } else {
519 PlotAxis::new()
520 .y_axis(false)
521 .y(px(VALUE_AXIS_GAP - TEXT_GAP * 2.))
522 .y_label(labels.map(|t| t.align(TextAlign::Right)))
523 };
524 value_axis.paint(&bounds, window, cx);
525 }
526
527 let band_fn_cloned = band_fn.clone();
529 let value_fn_cloned = value_fn.clone();
530 let default_fill: Background = cx.theme().chart_2.into();
531 let fill = self.fill.clone();
532 let fill_gradient = self.fill_gradient.clone();
533 let label_color = cx.theme().foreground;
534
535 let chart_bounds: Bounds<f32> = Bounds {
539 origin: Point::new(0., 0.),
540 size: Size::new(total_width, total_height),
541 };
542
543 let chart_range = {
546 let mut lo = 0.0_f32;
547 let mut hi = 0.0_f32;
548 for v in &self.data {
549 if let Some(f) = value_fn(v).to_f32() {
550 lo = lo.min(f);
551 hi = hi.max(f);
552 }
553 }
554 lo..=hi
555 };
556
557 let mut bar = Bar::new()
558 .data(&self.data)
559 .alignment(alignment)
560 .band_width(band_width)
561 .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
562 .base(move |_| zero_pixel)
563 .value(move |d| value_scale.tick(&value_fn_cloned(d)))
564 .corner_radii(self.corner_radii);
565
566 bar = match (fill, fill_gradient) {
567 (_, Some(fg)) => {
568 let value_fn_for_grad = value_fn.clone();
569 bar.fill(move |d, _frame, alignment| {
570 let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
571 let base_v = 0.0_f32;
572 let bar_lo = base_v.min(v);
573 let bar_hi = base_v.max(v);
574 let bar_span = (bar_hi - bar_lo).max(f32::EPSILON);
575 let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
576 let stops = fg(d, chart_range.clone(), &chart_to_bar);
577 let [s0, s1] = clip_stops_to_bar(stops);
578 let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
579 bg
580 })
581 }
582 (Some(f), _) => {
583 bar.fill(move |d, frame, alignment| f(d, frame, chart_bounds, alignment))
584 }
585 _ => bar.fill(move |_, _, _| default_fill),
586 };
587
588 if let Some(label) = self.label.as_ref() {
589 let label = label.clone();
590 let text_align = match alignment {
591 BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
592 BarAlignment::Left => TextAlign::Left,
593 BarAlignment::Right => TextAlign::Right,
594 };
595 bar =
596 bar.label(move |d, p| vec![Text::new(label(d), p, label_color).align(text_align)]);
597 }
598
599 bar.paint(&bounds, window, cx);
600 }
601
602 fn id(&self) -> Option<ElementId> {
603 self.id.clone()
604 }
605
606 fn tooltip_state(
607 &self,
608 position: Point<Pixels>,
609 bounds: Bounds<Pixels>,
610 _cx: &App,
611 ) -> Option<TooltipState> {
612 let band_fn = self.band.as_ref()?;
613 self.value.as_ref()?;
614
615 let is_horizontal = self.alignment.is_horizontal();
618 let band_scale = self.band_scale(bounds)?;
619 let band_width = band_scale.band_width();
620
621 let band_offset = self.band_offset();
622 let cursor_band = if is_horizontal {
623 position.y
624 } else {
625 position.x
626 };
627 let index = band_scale.least_index(cursor_band.as_f32() - band_offset);
628 let d = self.data.get(index)?;
629 let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
630
631 let cross_line = if is_horizontal {
634 point(position.x, px(center))
635 } else {
636 point(px(center), position.y)
637 };
638
639 Some(TooltipState::new(index, cross_line, vec![]))
640 }
641
642 fn tooltip(
643 &self,
644 state: &TooltipState,
645 cursor: Point<Pixels>,
646 bounds: Bounds<Pixels>,
647 window: &mut Window,
648 cx: &mut App,
649 ) -> Option<AnyElement> {
650 let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
651 let d = self.data.get(state.index)?;
652 let title: SharedString = band_fn(d).into();
653 let value = value_fn(d).to_f64()?;
654 let name = self.name.clone().unwrap_or_default();
655
656 let band_width = self.band_scale(bounds)?.band_width();
659 let cross_line = if self.alignment.is_horizontal() {
660 let (band_gap, value_end_gap) = self.horizontal_gaps(window);
661 let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
662 let start = if matches!(self.alignment, BarAlignment::Left) {
663 band_gap
664 } else {
665 value_end_gap
666 };
667 let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
669 if cursor.x.as_f32() < start
670 || cursor.x.as_f32() > start + length
671 || (self.value_axis && cursor.y.as_f32() > value_labels_top)
672 {
673 return None;
674 }
675 CrossLine::new(state.cross_line)
676 .horizontal()
677 .h_span(start, length)
678 .band(px(band_width))
679 } else {
680 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
681 let length = bounds.size.height.as_f32() - axis_gap;
682 let start = if matches!(self.alignment, BarAlignment::Top) {
683 axis_gap
684 } else {
685 0.
686 };
687 if cursor.y.as_f32() < start
689 || cursor.y.as_f32() > start + length
690 || cursor.x.as_f32() < self.band_offset()
691 {
692 return None;
693 }
694 CrossLine::new(state.cross_line)
695 .span(start, length)
696 .band(px(band_width))
697 };
698
699 Some(
700 Tooltip::new(cursor, bounds.size)
702 .gap(px(8.))
703 .cross_line(cross_line)
704 .title(title)
705 .row(cx.theme().chart_2, name, format!("{}", value))
706 .into_any_element(),
707 )
708 }
709}
710
711fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
722 let [a, b] = stops;
723 let p0 = a.percentage;
724 let p1 = b.percentage;
725 let lerp = |t: f32| -> Hsla {
726 Hsla {
727 h: a.color.h + (b.color.h - a.color.h) * t,
728 s: a.color.s + (b.color.s - a.color.s) * t,
729 l: a.color.l + (b.color.l - a.color.l) * t,
730 a: a.color.a + (b.color.a - a.color.a) * t,
731 }
732 };
733 let span = p1 - p0;
734 let sample = |target: f32| -> Hsla {
735 if span.abs() < f32::EPSILON {
736 a.color
737 } else {
738 lerp((target - p0) / span)
739 }
740 };
741 let new_a = if (0. ..=1.).contains(&p0) {
742 a
743 } else {
744 LinearColorStop {
745 color: sample(p0.clamp(0., 1.)),
746 percentage: p0.clamp(0., 1.),
747 }
748 };
749 let new_b = if (0. ..=1.).contains(&p1) {
750 b
751 } else {
752 LinearColorStop {
753 color: sample(p1.clamp(0., 1.)),
754 percentage: p1.clamp(0., 1.),
755 }
756 };
757 [new_a, new_b]
758}
759
760fn format_tick(v: f32) -> String {
762 if (v - v.round()).abs() < 0.001 {
763 format!("{:.0}", v)
764 } else {
765 format!("{:.1}", v)
766 }
767}
768
769fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
776 (value < 0.) == (alignment == BarAlignment::Top)
777}
778
779fn value_tick_positions(far: f32, baseline: f32, steps: usize) -> Vec<f32> {
785 (0..=steps)
786 .map(|i| far + (baseline - far) * i as f32 / steps as f32)
787 .collect()
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn test_label_below_zero_line() {
796 assert!(label_below_zero_line(5., BarAlignment::Bottom));
798 assert!(label_below_zero_line(0., BarAlignment::Bottom));
799 assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
800
801 assert!(!label_below_zero_line(5., BarAlignment::Top));
803 assert!(!label_below_zero_line(0., BarAlignment::Top));
804 assert!(label_below_zero_line(-5., BarAlignment::Top));
805 }
806
807 #[test]
808 fn test_value_tick_positions() {
809 assert_eq!(
811 value_tick_positions(10., 110., 4),
812 vec![10., 35., 60., 85., 110.]
813 );
814
815 assert_eq!(value_tick_positions(110., 10., 2), vec![110., 60., 10.]);
817
818 assert_eq!(value_tick_positions(0., 50., 1), vec![0., 50.]);
819 }
820}