1use std::{hash::Hash, ops::RangeInclusive, rc::Rc};
2
3use gpui_kit::{
4 AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop,
5 Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px,
6};
7
8use crate::{
9 ActiveTheme,
10 plot::{
11 AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel,
12 label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
13 scale::{PlotValue, Scale, ScaleBand, ScaleLinear},
14 shape::{Bar, BarAlignment},
15 tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
16 },
17};
18
19use super::{
20 AXIS_GAP, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, build_band_labels,
21 caller_id, format_tick, labeled_items, value_axis_gap,
22};
23
24const HOVER_DIM: f32 = 0.45;
26
27#[derive(Clone, Copy)]
29struct BarHover {
30 center: f32,
32 focus: f32,
34}
35
36pub struct BarChart<T, B, V>
37where
38 T: 'static,
39 B: Eq + Hash + Into<SharedString> + 'static,
40 V: PlotValue,
41{
42 data: Vec<T>,
43 band: Option<Rc<dyn Fn(&T) -> B>>,
44 value: Option<Rc<dyn Fn(&T) -> V>>,
45 fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
46 #[allow(clippy::type_complexity)]
47 fill_gradient:
48 Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
49 tick_margin: usize,
50 label: Option<Rc<dyn Fn(&T) -> SharedString>>,
51 label_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
52 label_axis: bool,
53 value_axis: bool,
54 value_axis_label_placement: AxisLabelPlacement,
55 value_tick_count: usize,
56 value_tick_format: Option<TickFormat>,
57 band_count: Option<usize>,
58 band_tick_count: Option<usize>,
59 grid: bool,
60 grid_dashed: bool,
61 alignment: BarAlignment,
62 corner_radii: Corners<Pixels>,
63 padding_inner: f32,
64 padding_outer: f32,
65 max_band_width: Pixels,
66 min_length: f32,
67 id: ElementId,
68 interactive: bool,
69 name: Option<SharedString>,
70 tooltip_content: TooltipContent<T>,
71 horizontal_gaps: (f32, f32),
74 value_label_gap: f32,
77 hover: Option<BarHover>,
78}
79
80impl<T, B, V> BarChart<T, B, V>
81where
82 B: Eq + Hash + Into<SharedString> + 'static,
83 V: PlotValue,
84{
85 #[track_caller]
86 pub fn new<I>(data: I) -> Self
87 where
88 I: IntoIterator<Item = T>,
89 {
90 Self {
91 data: data.into_iter().collect(),
92 band: None,
93 value: None,
94 fill: None,
95 fill_gradient: None,
96 tick_margin: 1,
97 label: None,
98 label_color: None,
99 label_axis: true,
100 value_axis: false,
101 value_axis_label_placement: AxisLabelPlacement::default(),
102 value_tick_count: 5,
103 value_tick_format: None,
104 band_count: None,
105 band_tick_count: None,
106 grid: true,
107 grid_dashed: false,
108 alignment: BarAlignment::default(),
109 corner_radii: Corners::all(px(0.)),
110 padding_inner: 0.4,
111 padding_outer: 0.2,
112 max_band_width: px(MAX_BAND_WIDTH),
113 min_length: 0.,
114 id: caller_id(),
115 interactive: true,
116 name: None,
117 tooltip_content: TooltipContent::default(),
118 horizontal_gaps: (0., 0.),
119 value_label_gap: VALUE_AXIS_GAP,
120 hover: None,
121 }
122 }
123
124 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
131 self.id = id.into();
132 self
133 }
134
135 pub fn interactive(mut self, interactive: bool) -> Self {
144 self.interactive = interactive;
145 self
146 }
147
148 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
150 self.name = Some(name.into());
151 self
152 }
153
154 pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
156 self.tooltip_content.set_title(title);
157 self
158 }
159
160 pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
164 self.tooltip_content.set_value(move |d, _, v| value(d, v));
165 self
166 }
167
168 pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
174 where
175 H: Into<Hsla>,
176 {
177 self.tooltip_content
178 .set_value_color(move |d, _, value| color(d, value));
179 self
180 }
181
182 pub fn tooltip_content<E>(
189 mut self,
190 content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
191 ) -> Self
192 where
193 E: IntoElement,
194 {
195 self.tooltip_content.set_content(content);
196 self
197 }
198
199 pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
201 self.band = Some(Rc::new(band));
202 self
203 }
204
205 pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
207 self.value = Some(Rc::new(value));
208 self
209 }
210
211 pub fn fill<Bg>(
231 mut self,
232 fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
233 ) -> Self
234 where
235 Bg: Into<Background> + 'static,
236 {
237 self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
238 fill(t, bar_bounds, chart_bounds, alignment).into()
239 }));
240 self.fill_gradient = None;
241 self
242 }
243
244 pub fn fill_gradient(
280 mut self,
281 fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
282 ) -> Self {
283 self.fill_gradient = Some(Rc::new(fill));
284 self.fill = None;
285 self
286 }
287
288 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
289 self.tick_margin = tick_margin;
290 self
291 }
292
293 pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
294 where
295 S: Into<SharedString> + 'static,
296 {
297 self.label = Some(Rc::new(move |t| label(t).into()));
298 self
299 }
300
301 pub fn label_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
307 where
308 H: Into<Hsla> + 'static,
309 {
310 self.label_color = Some(Rc::new(move |t| color(t).into()));
311 self
312 }
313
314 pub fn label_axis(mut self, label_axis: bool) -> Self {
318 self.label_axis = label_axis;
319 self
320 }
321
322 pub fn value_axis(mut self, value_axis: bool) -> Self {
330 self.value_axis = value_axis;
331 self
332 }
333
334 pub fn value_tick_count(mut self, count: usize) -> Self {
343 self.value_tick_count = count.max(2);
344 self
345 }
346
347 pub fn value_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
352 self.value_axis_label_placement = placement;
353 self
354 }
355
356 pub fn value_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
360 where
361 S: Into<SharedString> + 'static,
362 {
363 self.value_tick_format = Some(Rc::new(move |value| format(value).into()));
364 self
365 }
366
367 pub fn band_count(mut self, count: usize) -> Self {
374 self.band_count = Some(count);
375 self
376 }
377
378 pub fn band_tick_count(mut self, count: usize) -> Self {
385 self.band_tick_count = Some(count);
386 self
387 }
388
389 pub fn grid(mut self, grid: bool) -> Self {
390 self.grid = grid;
391 self
392 }
393
394 pub fn grid_dashed(mut self, dashed: bool) -> Self {
398 self.grid_dashed = dashed;
399 self
400 }
401
402 pub fn alignment(mut self, alignment: BarAlignment) -> Self {
406 self.alignment = alignment;
407 self
408 }
409
410 pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
415 self.corner_radii = corner_radii.into();
416 self
417 }
418
419 pub fn padding_inner(mut self, padding: f32) -> Self {
423 self.padding_inner = padding;
424 self
425 }
426
427 pub fn padding_outer(mut self, padding: f32) -> Self {
431 self.padding_outer = padding;
432 self
433 }
434
435 pub fn max_band_width(mut self, width: impl Into<Pixels>) -> Self {
440 self.max_band_width = width.into();
441 self
442 }
443
444 pub fn min_length(mut self, length: f32) -> Self {
453 self.min_length = length;
454 self
455 }
456
457 fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
460 let band_fn = self.band.as_ref()?;
461 let band_extent = if self.alignment.is_horizontal() {
462 bounds.size.height.as_f32()
463 } else {
464 bounds.size.width.as_f32()
465 };
466 let extent = (band_extent - self.value_axis_gap()).max(0.);
469 Some(
470 ScaleBand::new(self.data.iter().map(|v| band_fn(v)), [0., extent])
471 .band_count(self.band_count.unwrap_or(0))
472 .max_band_width(self.max_band_width.as_f32())
473 .padding_inner(self.padding_inner)
474 .padding_outer(self.padding_outer),
475 )
476 }
477
478 fn band_offset(&self) -> f32 {
485 if self.alignment.is_horizontal() {
486 0.
487 } else {
488 self.value_axis_gap()
489 }
490 }
491
492 fn value_scale(&self, bounds: Bounds<Pixels>) -> Option<(ScaleLinear<V>, f32, f32)> {
496 let value_fn = self.value.as_ref()?;
497 let value_dim = if self.alignment.is_horizontal() {
498 bounds.size.width.as_f32()
499 } else {
500 bounds.size.height.as_f32()
501 };
502 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
503 let far_gap = if self.label.is_some() {
511 TEXT_HEIGHT
512 } else {
513 10.
514 };
515 let (band_gap, value_end_gap) = if self.alignment.is_horizontal() {
516 self.horizontal_gaps
517 } else {
518 (axis_gap, far_gap)
519 };
520 let (baseline, far) = match self.alignment {
522 BarAlignment::Bottom => (value_dim - axis_gap, far_gap),
523 BarAlignment::Top => (axis_gap, value_dim - far_gap),
524 BarAlignment::Left => (band_gap, value_dim - value_end_gap),
525 BarAlignment::Right => (value_dim - band_gap, value_end_gap),
526 };
527 let scale = ScaleLinear::new(
528 self.data.iter().map(|v| value_fn(v)).chain(Some(V::zero())),
529 [baseline, far],
530 );
531 Some((scale, baseline, far))
532 }
533
534 fn bar_frame(
536 &self,
537 d: &T,
538 band_scale: &ScaleBand<B>,
539 bounds: Bounds<Pixels>,
540 ) -> Option<Bounds<f32>> {
541 let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
542 let (value_scale, baseline, _) = self.value_scale(bounds)?;
543 let zero = value_scale.tick(&V::zero()).unwrap_or(baseline);
544 let cross = band_scale.tick(&band_fn(d))? + self.band_offset();
545 let end = bar_end(
546 &value_scale,
547 value_fn(d),
548 zero,
549 self.alignment,
550 self.min_length,
551 )?;
552 let (lo, length) = (end.min(zero), (end - zero).abs());
553 let band_width = band_scale.band_width();
554 Some(if self.alignment.is_horizontal() {
555 Bounds {
556 origin: Point::new(lo, cross),
557 size: Size::new(length, band_width),
558 }
559 } else {
560 Bounds {
561 origin: Point::new(cross, lo),
562 size: Size::new(band_width, length),
563 }
564 })
565 }
566
567 fn gradient_range(&self) -> RangeInclusive<f32> {
569 let Some(value_fn) = self.value.as_ref() else {
570 return 0.0..=0.0;
571 };
572 let mut lo = 0.0_f32;
573 let mut hi = 0.0_f32;
574 for v in &self.data {
575 if let Some(f) = value_fn(v).to_f32() {
576 lo = lo.min(f);
577 hi = hi.max(f);
578 }
579 }
580 lo..=hi
581 }
582
583 fn bar_color(&self, d: &T, frame: Bounds<f32>, bounds: Bounds<Pixels>, cx: &App) -> Hsla {
588 let default = cx.macos().chart[0];
589 if let Some(fill) = self.fill_gradient.as_ref() {
590 let value = self
591 .value
592 .as_ref()
593 .and_then(|value_fn| value_fn(d).to_f32())
594 .unwrap_or(0.);
595 let [first, _] = bar_gradient(fill.as_ref(), d, value, self.gradient_range());
596 return first.color;
597 }
598 let Some(fill) = self.fill.as_ref() else {
599 return default;
600 };
601 let chart_bounds = Bounds {
602 origin: Point::new(0., 0.),
603 size: Size::new(bounds.size.width.as_f32(), bounds.size.height.as_f32()),
604 };
605 fill(d, frame, chart_bounds, self.alignment)
606 .as_solid()
607 .unwrap_or(default)
608 }
609
610 fn value_axis_gap(&self) -> f32 {
613 if !self.value_axis || self.value_axis_label_placement != AxisLabelPlacement::Outside {
614 0.
615 } else if self.alignment.is_horizontal() {
616 VALUE_AXIS_GAP
618 } else {
619 self.value_label_gap
620 }
621 }
622
623 fn band_slots(&self) -> usize {
626 self.band_count.unwrap_or(0).max(self.data.len())
627 }
628
629 fn value_tick_labels(&self) -> Vec<SharedString> {
632 let Some(value_fn) = self.value.as_ref() else {
633 return vec![];
634 };
635 let (lo, hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
637 let f = value_fn(v).to_f32().unwrap_or(0.);
638 (lo.min(f), hi.max(f))
639 });
640 let steps = (self.value_tick_count - 1) as f32;
641 (0..self.value_tick_count)
642 .map(|i| {
643 let value = (hi - (hi - lo) * i as f32 / steps) as f64;
644 match self.value_tick_format.as_ref() {
645 Some(format) => format(value),
646 None => format_tick(value),
647 }
648 })
649 .collect()
650 }
651
652 fn measure_horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
657 let Some(band_fn) = self.band.as_ref() else {
658 return (0., 0.);
659 };
660 let font_size = px(TEXT_SIZE);
661 let band_gap = if self.label_axis {
662 let labeled = labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin);
663 self.data
664 .iter()
665 .enumerate()
666 .filter(|(i, _)| labeled.get(*i).copied().unwrap_or(false))
667 .map(|(_, v)| {
668 let s: SharedString = band_fn(v).into();
669 measure_text_width(&s, font_size, window)
670 })
671 .fold(0f32, f32::max)
672 + TEXT_GAP * 2.
673 } else {
674 0.
675 };
676 let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
677 self.data
678 .iter()
679 .map(|v| measure_text_width(&label_fn(v), font_size, window))
680 .fold(0f32, f32::max)
681 + TEXT_GAP * 2.
682 } else {
683 TEXT_GAP * 4.
684 };
685 (band_gap, value_end_gap)
686 }
687
688 fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
691 if self.alignment.is_horizontal() {
692 let (band_gap, value_end_gap) = self.horizontal_gaps;
693 let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
694 let start = if matches!(self.alignment, BarAlignment::Left) {
695 band_gap
696 } else {
697 value_end_gap
698 };
699 (start, length)
700 } else {
701 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
702 let length = bounds.size.height.as_f32() - axis_gap;
703 let start = if matches!(self.alignment, BarAlignment::Top) {
704 axis_gap
705 } else {
706 0.
707 };
708 (start, length)
709 }
710 }
711
712 fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
714 let (start, length) = self.value_extent(bounds);
715 if self.alignment.is_horizontal() {
716 let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
717 (start..=start + length).contains(&position.x.as_f32())
718 && !(self.value_axis_gap() > 0. && position.y.as_f32() > value_labels_top)
719 } else {
720 (start..=start + length).contains(&position.y.as_f32())
721 && position.x.as_f32() >= self.band_offset()
722 }
723 }
724}
725
726crate::impl_into_plot!(impl<T, B, V> BarChart<T, B, V> where B: Eq + Hash + Into<SharedString> + 'static, V: PlotValue);
727
728impl<T, B, V> Plot for BarChart<T, B, V>
729where
730 B: Eq + Hash + Into<SharedString> + 'static,
731 V: PlotValue,
732{
733 fn prepaint(
734 &mut self,
735 _bounds: Bounds<Pixels>,
736 window: &mut Window,
737 _cx: &mut App,
738 ) -> Vec<AnyElement> {
739 self.horizontal_gaps = if self.alignment.is_horizontal() {
740 self.measure_horizontal_gaps(window)
741 } else {
742 (0., 0.)
743 };
744 if self.value_axis
745 && self.value_axis_label_placement == AxisLabelPlacement::Outside
746 && !self.alignment.is_horizontal()
747 {
748 self.value_label_gap = value_axis_gap(self.value_tick_labels(), window);
749 }
750 vec![]
751 }
752
753 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
754 let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
755 return;
756 };
757
758 let total_width = bounds.size.width.as_f32();
759 let total_height = bounds.size.height.as_f32();
760 let alignment = self.alignment;
761 let is_horizontal = alignment.is_horizontal();
762
763 let Some(band_scale) = self.band_scale(bounds) else {
766 return;
767 };
768 let band_width = band_scale.band_width();
769
770 let Some((value_scale, baseline, far)) = self.value_scale(bounds) else {
771 return;
772 };
773
774 let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
778 let band_offset = self.band_offset();
779
780 let value_axis_gap = self.value_axis_gap();
784 let plot_bounds = if is_horizontal {
785 Bounds {
786 origin: bounds.origin,
787 size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
788 }
789 } else {
790 Bounds {
791 origin: bounds.origin + point(px(value_axis_gap), px(0.)),
792 size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
793 }
794 };
795
796 let mut axis = PlotAxis::new().stroke(cx.macos().border);
798 if self.label_axis {
799 match alignment {
800 BarAlignment::Bottom | BarAlignment::Top => {
801 axis = axis.x(zero_pixel);
802
803 let labeled =
808 labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin);
809 let labels = self
810 .data
811 .iter()
812 .enumerate()
813 .filter(|(i, _)| labeled[*i])
814 .filter_map(|(_, d)| {
815 let band_x = band_scale.tick(&band_fn(d))?;
816 let value = value_fn(d).to_f32().unwrap_or(0.);
817 let label_y = if label_below_zero_line(value, alignment) {
818 zero_pixel + TEXT_GAP
819 } else {
820 zero_pixel - TEXT_GAP - TEXT_SIZE
821 };
822
823 Some(
824 Text::new(
825 band_fn(d).into(),
826 point(px(band_x + band_offset + band_width / 2.), px(label_y)),
827 cx.macos().secondary,
828 )
829 .align(TextAlign::Center),
830 )
831 })
832 .collect();
833 PlotLabel::new(labels).paint(&bounds, window, cx);
834 }
835 BarAlignment::Left | BarAlignment::Right => {
836 let labels = build_band_labels(
837 &self.data,
838 band_fn.as_ref(),
839 &band_scale,
840 band_width,
841 &labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin),
842 cx.macos().secondary,
843 );
844 let (side, align) = if matches!(alignment, BarAlignment::Left) {
845 (AxisLabelSide::Start, TextAlign::Right)
846 } else {
847 (AxisLabelSide::End, TextAlign::Left)
848 };
849 axis = axis
850 .y(zero_pixel)
851 .y_label_side(side)
852 .y_label(labels.into_iter().map(|t| t.align(align)));
853 }
854 }
855 }
856 axis.paint(&plot_bounds, window, cx);
857
858 let value_ticks = value_tick_positions(far, baseline, self.value_tick_count);
859 let steps = value_ticks.len() - 1;
860
861 if self.grid {
863 let grid = Grid::new().stroke(cx.macos().divider());
864 let grid = if self.grid_dashed {
865 grid.dash_array(&[px(4.), px(2.)])
866 } else {
867 grid
868 };
869 let lines = value_ticks[..steps].to_vec();
870 let grid = if is_horizontal {
871 grid.x(lines)
872 } else {
873 grid.y(lines)
874 };
875 grid.paint(&plot_bounds, window);
876 }
877
878 let mut inside_labels = None;
880 if self.value_axis {
881 let color = cx.macos().secondary;
884 let texts = self
885 .value_tick_labels()
886 .into_iter()
887 .zip(value_ticks.iter().copied());
888
889 match self.value_axis_label_placement {
890 AxisLabelPlacement::Outside => {
894 let labels = texts.map(|(text, tick)| AxisText::new(text, px(tick), color));
895 let value_axis = if is_horizontal {
896 PlotAxis::new()
897 .x_axis(false)
898 .x(px(total_height - VALUE_AXIS_GAP))
899 .x_label(labels.map(|t| t.align(TextAlign::Center)))
900 } else {
901 PlotAxis::new()
902 .y_axis(false)
903 .y(px(value_axis_gap - TEXT_GAP * 2.))
904 .y_label(labels.map(|t| t.align(TextAlign::Right)))
905 };
906 value_axis.paint(&bounds, window, cx);
907 }
908 AxisLabelPlacement::Inside => {
912 let labels = texts
913 .map(|(text, tick)| {
914 if is_horizontal {
915 Text::new(text, point(tick, total_height - TEXT_HEIGHT), color)
916 .align(TextAlign::Center)
917 } else {
918 let top = if tick < TEXT_HEIGHT {
919 tick + TEXT_GAP
920 } else {
921 tick - TEXT_HEIGHT
922 };
923 Text::new(text, point(TEXT_GAP, top), color)
924 }
925 })
926 .collect();
927 inside_labels = Some(PlotLabel::new(labels));
928 }
929 }
930 }
931
932 let band_fn_cloned = band_fn.clone();
934 let value_fn_cloned = value_fn.clone();
935 let default_fill: Background = cx.macos().chart[0].into();
936 let fill = self.fill.clone();
937 let fill_gradient = self.fill_gradient.clone();
938 let label_color = cx.macos().foreground;
939 let label_color_fn = self.label_color.clone();
940 let min_length = self.min_length;
941
942 let chart_bounds: Bounds<f32> = Bounds {
946 origin: Point::new(0., 0.),
947 size: Size::new(total_width, total_height),
948 };
949
950 let chart_range = if fill_gradient.is_some() {
953 self.gradient_range()
954 } else {
955 0.0..=0.0
956 };
957
958 let hover = self.hover;
962 let step = band_scale.step().max(f32::EPSILON);
963 let emphasis = move |frame: Bounds<f32>| -> f32 {
964 let Some(hover) = hover else {
965 return 1.;
966 };
967 let center = if is_horizontal {
968 frame.origin.y + frame.size.height / 2.
969 } else {
970 frame.origin.x + frame.size.width / 2.
971 };
972 let distance = ((center - hover.center).abs() / step).min(1.);
973 1. - HOVER_DIM * hover.focus * distance
974 };
975
976 let mut bar = Bar::new()
977 .data(&self.data)
978 .alignment(alignment)
979 .band_width(band_width)
980 .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
981 .base(move |_| zero_pixel)
982 .value(move |d| {
983 bar_end(
984 &value_scale,
985 value_fn_cloned(d),
986 zero_pixel,
987 alignment,
988 min_length,
989 )
990 })
991 .corner_radii(self.corner_radii);
992
993 bar = match (fill, fill_gradient) {
994 (_, Some(fg)) => {
995 let value_fn_for_grad = value_fn.clone();
996 bar.fill(move |d, frame, alignment| {
997 let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
998 let [s0, s1] = bar_gradient(fg.as_ref(), d, v, chart_range.clone());
999 let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
1000 bg.opacity(emphasis(frame))
1001 })
1002 }
1003 (Some(f), _) => bar.fill(move |d, frame, alignment| {
1004 f(d, frame, chart_bounds, alignment).opacity(emphasis(frame))
1005 }),
1006 _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))),
1007 };
1008
1009 if let Some(label) = self.label.as_ref() {
1010 let label = label.clone();
1011 let text_align = match alignment {
1012 BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
1013 BarAlignment::Left => TextAlign::Left,
1014 BarAlignment::Right => TextAlign::Right,
1015 };
1016 bar = bar.label(move |d, p| {
1017 let color = label_color_fn.as_ref().map_or(label_color, |f| f(d));
1018 vec![Text::new(label(d), p, color).align(text_align)]
1019 });
1020 }
1021
1022 bar.paint(&bounds, window, cx);
1023 if let Some(labels) = inside_labels {
1024 labels.paint(&bounds, window, cx);
1025 }
1026 }
1027
1028 fn id(&self) -> Option<ElementId> {
1029 self.interactive.then(|| self.id.clone())
1030 }
1031
1032 fn tooltip_state(
1033 &self,
1034 position: Point<Pixels>,
1035 bounds: Bounds<Pixels>,
1036 _cx: &App,
1037 ) -> Option<TooltipState> {
1038 let band_fn = self.band.as_ref()?;
1039 self.value.as_ref()?;
1040
1041 if !self.is_over_bars(position, bounds) {
1043 return None;
1044 }
1045
1046 let is_horizontal = self.alignment.is_horizontal();
1049 let band_scale = self.band_scale(bounds)?;
1050 let band_width = band_scale.band_width();
1051
1052 let band_offset = self.band_offset();
1053 let cursor_band = if is_horizontal {
1054 position.y
1055 } else {
1056 position.x
1057 };
1058 let index = band_scale.nearest_index(cursor_band.as_f32() - band_offset);
1059 let d = self.data.get(index)?;
1060 let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
1061
1062 let cross_line = if is_horizontal {
1065 point(position.x, px(center))
1066 } else {
1067 point(px(center), position.y)
1068 };
1069
1070 Some(TooltipState::new(index, cross_line, vec![]))
1071 }
1072
1073 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
1074 self.hover = hover.map(|hover| {
1075 let target = if self.alignment.is_horizontal() {
1078 hover.state().cross_line.y
1079 } else {
1080 hover.state().cross_line.x
1081 };
1082 let center = hover.glide(("bar-chart", "band"), target, window, cx);
1083 BarHover {
1084 center: center.as_f32(),
1085 focus: hover.progress(),
1086 }
1087 });
1088 }
1089
1090 fn tooltip(
1091 &self,
1092 state: &TooltipState,
1093 cursor: Point<Pixels>,
1094 bounds: Bounds<Pixels>,
1095 window: &mut Window,
1096 cx: &mut App,
1097 ) -> Option<AnyElement> {
1098 let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
1099 let d = self.data.get(state.index)?;
1100 let name = self.name.clone().unwrap_or_default();
1101
1102 let band_scale = self.band_scale(bounds)?;
1106 let band_width = band_scale.band_width();
1107 let center = self.hover.map_or(state.cross_line, |hover| {
1108 if self.alignment.is_horizontal() {
1109 point(state.cross_line.x, px(hover.center))
1110 } else {
1111 point(px(hover.center), state.cross_line.y)
1112 }
1113 });
1114 let (start, length) = self.value_extent(bounds);
1115 let cross_line = if self.alignment.is_horizontal() {
1116 CrossLine::new(center)
1117 .horizontal()
1118 .h_span(start, length)
1119 .band(px(band_width))
1120 } else {
1121 CrossLine::new(center)
1122 .span(start, length)
1123 .band(px(band_width))
1124 };
1125
1126 let frame = self.bar_frame(d, &band_scale, bounds).unwrap_or_default();
1127 let swatch = self.bar_color(d, frame, bounds, cx);
1128
1129 let tooltip = Tooltip::new(cursor, bounds.size)
1131 .glide(false)
1132 .gap(px(8.))
1133 .cross_line(cross_line);
1134
1135 let tooltip = self.tooltip_content.apply(
1136 tooltip,
1137 d,
1138 || Some(band_fn(d).into()),
1139 || Some([(swatch, name, value_fn(d).to_f64()?)]),
1140 window,
1141 cx,
1142 )?;
1143
1144 Some(tooltip.into_any_element())
1145 }
1146}
1147
1148fn bar_end<V>(
1151 scale: &ScaleLinear<V>,
1152 value: V,
1153 zero: f32,
1154 alignment: BarAlignment,
1155 min_length: f32,
1156) -> Option<f32>
1157where
1158 V: PlotValue,
1159{
1160 let tick = scale.tick(&value)?;
1161 Some(extend_to_min_length(
1162 tick,
1163 zero,
1164 value < V::zero(),
1165 alignment,
1166 min_length,
1167 ))
1168}
1169
1170fn extend_to_min_length(
1173 tick: f32,
1174 zero: f32,
1175 negative: bool,
1176 alignment: BarAlignment,
1177 min: f32,
1178) -> f32 {
1179 if (tick - zero).abs() >= min {
1180 return tick;
1181 }
1182 let grows_toward_origin = matches!(alignment, BarAlignment::Bottom | BarAlignment::Right);
1183 if grows_toward_origin != negative {
1184 zero - min
1185 } else {
1186 zero + min
1187 }
1188}
1189
1190fn bar_gradient<T>(
1193 fill: &dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2],
1194 d: &T,
1195 value: f32,
1196 range: RangeInclusive<f32>,
1197) -> [LinearColorStop; 2] {
1198 let bar_lo = value.min(0.);
1199 let bar_span = (value.max(0.) - bar_lo).max(f32::EPSILON);
1200 let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
1201 clip_stops_to_bar(fill(d, range, &chart_to_bar))
1202}
1203
1204fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
1215 let [a, b] = stops;
1216 let p0 = a.percentage;
1217 let p1 = b.percentage;
1218 let lerp = |t: f32| -> Hsla {
1219 Hsla {
1220 h: a.color.h + (b.color.h - a.color.h) * t,
1221 s: a.color.s + (b.color.s - a.color.s) * t,
1222 l: a.color.l + (b.color.l - a.color.l) * t,
1223 a: a.color.a + (b.color.a - a.color.a) * t,
1224 }
1225 };
1226 let span = p1 - p0;
1227 let sample = |target: f32| -> Hsla {
1228 if span.abs() < f32::EPSILON {
1229 a.color
1230 } else {
1231 lerp((target - p0) / span)
1232 }
1233 };
1234 let new_a = if (0. ..=1.).contains(&p0) {
1235 a
1236 } else {
1237 LinearColorStop {
1238 color: sample(p0.clamp(0., 1.)),
1239 percentage: p0.clamp(0., 1.),
1240 }
1241 };
1242 let new_b = if (0. ..=1.).contains(&p1) {
1243 b
1244 } else {
1245 LinearColorStop {
1246 color: sample(p1.clamp(0., 1.)),
1247 percentage: p1.clamp(0., 1.),
1248 }
1249 };
1250 [new_a, new_b]
1251}
1252
1253fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
1260 (value < 0.) == (alignment == BarAlignment::Top)
1261}
1262
1263fn value_tick_positions(far: f32, baseline: f32, count: usize) -> Vec<f32> {
1268 let steps = (count - 1) as f32;
1269 (0..count)
1270 .map(|i| far + (baseline - far) * i as f32 / steps)
1271 .collect()
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use super::*;
1277 use gpui_kit::{Context, Render, TestAppContext, div, prelude::*};
1278 use std::cell::Cell;
1279
1280 struct CountedBars {
1281 value_axis: bool,
1282 reads: Rc<Cell<usize>>,
1283 formats: Rc<Cell<usize>>,
1284 }
1285
1286 impl Render for CountedBars {
1287 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1288 let reads = self.reads.clone();
1289 let formats = self.formats.clone();
1290 div().w(px(640.)).h(px(320.)).child(
1291 BarChart::new(0..2048)
1292 .interactive(false)
1293 .band(|index| index.to_string())
1294 .value(move |index| {
1295 reads.set(reads.get() + 1);
1296 *index as f64
1297 })
1298 .label_axis(false)
1299 .grid(false)
1300 .value_axis(self.value_axis)
1301 .value_axis_label_placement(AxisLabelPlacement::Inside)
1302 .value_tick_format(move |value| {
1303 formats.set(formats.get() + 1);
1304 value.to_string()
1305 }),
1306 )
1307 }
1308 }
1309
1310 #[gpui_kit::test]
1311 fn solid_bar_rendering_skips_unused_gradient_and_outside_label_scans(cx: &mut TestAppContext) {
1312 cx.update(crate::init);
1313 for value_axis in [false, true] {
1314 let reads = Rc::new(Cell::new(0));
1315 let formats = Rc::new(Cell::new(0));
1316 let (_, cx) = cx.add_window_view({
1317 let reads = reads.clone();
1318 let formats = formats.clone();
1319 move |_, _| CountedBars {
1320 value_axis,
1321 reads,
1322 formats,
1323 }
1324 });
1325 reads.set(0);
1326 formats.set(0);
1327 cx.update(|window, cx| {
1328 window.refresh();
1329 window.draw(cx).clear(cx);
1330 });
1331 assert!(reads.get() >= 2048, "The complete series must be painted");
1332 assert_eq!(reads.get(), 2048 * if value_axis { 3 } else { 2 });
1333 assert_eq!(formats.get(), if value_axis { 5 } else { 0 });
1334 }
1335 }
1336
1337 #[gpui_kit::test]
1338 fn horizontal_bar_gutters_only_measure_displayed_ticks(cx: &mut TestAppContext) {
1339 cx.update(crate::init);
1340 let cx = cx.add_empty_window();
1341 let measured = Rc::new(std::cell::RefCell::new(Vec::new()));
1342 let observed = measured.clone();
1343 let chart = BarChart::new([0, 1, 2])
1344 .band(move |index| {
1345 observed.borrow_mut().push(*index);
1346 if *index == 1 {
1347 "A very wide label that is not displayed"
1348 } else {
1349 "Short"
1350 }
1351 })
1352 .value(|index| *index as f64)
1353 .alignment(BarAlignment::Left)
1354 .band_tick_count(2);
1355 cx.update(|window, _| {
1356 let (gap, _) = chart.measure_horizontal_gaps(window);
1357 assert_eq!(
1358 gap,
1359 measure_text_width(&"Short".into(), px(TEXT_SIZE), window) + TEXT_GAP * 2.
1360 );
1361 });
1362 assert_eq!(*measured.borrow(), [0, 2]);
1363 }
1364
1365 #[test]
1366 fn test_label_below_zero_line() {
1367 assert!(label_below_zero_line(5., BarAlignment::Bottom));
1369 assert!(label_below_zero_line(0., BarAlignment::Bottom));
1370 assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
1371
1372 assert!(!label_below_zero_line(5., BarAlignment::Top));
1374 assert!(!label_below_zero_line(0., BarAlignment::Top));
1375 assert!(label_below_zero_line(-5., BarAlignment::Top));
1376 }
1377
1378 #[test]
1379 fn test_value_tick_positions() {
1380 assert_eq!(
1382 value_tick_positions(10., 110., 5),
1383 vec![10., 35., 60., 85., 110.]
1384 );
1385
1386 assert_eq!(value_tick_positions(110., 10., 3), vec![110., 60., 10.]);
1388
1389 assert_eq!(value_tick_positions(0., 50., 2), vec![0., 50.]);
1390 }
1391
1392 #[test]
1393 fn test_min_length_extends_away_from_zero() {
1394 assert_eq!(
1396 extend_to_min_length(100., 100., false, BarAlignment::Bottom, 2.),
1397 98.
1398 );
1399 assert_eq!(
1400 extend_to_min_length(10., 10., false, BarAlignment::Top, 2.),
1401 12.
1402 );
1403 assert_eq!(
1404 extend_to_min_length(10., 10., false, BarAlignment::Left, 2.),
1405 12.
1406 );
1407 assert_eq!(
1408 extend_to_min_length(90., 90., false, BarAlignment::Right, 2.),
1409 88.
1410 );
1411
1412 assert_eq!(
1414 extend_to_min_length(50.5, 50., true, BarAlignment::Bottom, 2.),
1415 52.
1416 );
1417
1418 assert_eq!(
1420 extend_to_min_length(40., 100., false, BarAlignment::Bottom, 2.),
1421 40.
1422 );
1423 }
1424
1425 #[test]
1426 fn value_tick_labels_walk_the_domain_from_the_far_end() {
1427 use super::BarChart;
1428
1429 let chart = BarChart::new([10., 20.])
1430 .band(|v| format!("{v}"))
1431 .value(|v| *v)
1432 .value_tick_count(3);
1433 assert_eq!(chart.value_tick_labels(), vec!["20", "10", "0"]);
1434
1435 let money = chart.value_tick_format(|v| format!("${v:.0}"));
1436 assert_eq!(money.value_tick_labels(), vec!["$20", "$10", "$0"]);
1437
1438 assert_eq!(money.band_count(12).band_slots(), 12);
1440 }
1441
1442 #[test]
1443 fn a_band_count_keeps_each_bar_in_its_band() {
1444 use gpui_kit::{Bounds, point, px, size};
1445
1446 use super::BarChart;
1447 use crate::plot::{AxisLabelPlacement, scale::Scale};
1448
1449 let bounds = Bounds::new(point(px(0.), px(0.)), size(px(40.), px(100.)));
1450 let chart = |data: &[f64], count| {
1451 BarChart::new(data.to_vec())
1452 .band(|v| format!("{v}"))
1453 .value(|v| *v)
1454 .band_count(count)
1455 };
1456
1457 let wide = chart(&[1., 2.], 2).band_scale(bounds).unwrap();
1459 let narrow = chart(&[1., 2.], 4).band_scale(bounds).unwrap();
1460 assert_eq!(narrow.band_width() * 2., wide.band_width());
1461 assert!(narrow.tick(&"2".to_string()).unwrap() < 20.);
1462
1463 let grown = chart(&[1., 2., 3.], 4).band_scale(bounds).unwrap();
1465 assert_eq!(grown.tick(&"2".to_string()), narrow.tick(&"2".to_string()));
1466 assert_eq!(grown.band_width(), narrow.band_width());
1467
1468 let outside = chart(&[1., 2.], 2).value_axis(true);
1470 let inside = chart(&[1., 2.], 2)
1471 .value_axis(true)
1472 .value_axis_label_placement(AxisLabelPlacement::Inside);
1473 assert_eq!(outside.value_axis_gap(), super::VALUE_AXIS_GAP);
1474 assert_eq!(inside.value_axis_gap(), 0.);
1475 }
1476
1477 #[gpui_kit::test]
1480 fn the_tooltip_swatch_follows_the_bar_color(cx: &mut gpui_kit::TestAppContext) {
1481 cx.update(crate::init);
1482 let bars = || {
1483 BarChart::new([1., -2.])
1484 .band(|d: &f64| SharedString::from(format!("{d}")))
1485 .value(|d: &f64| *d)
1486 };
1487 let frame = Bounds::default();
1488 let bounds = Bounds::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)));
1489 let (default, solid, gradient, stops) = cx.update(|cx| {
1490 let gain = gpui_kit::green();
1491 let loss = gpui_kit::red();
1492 let default = bars().bar_color(&1., frame, bounds, cx);
1493 let solid = bars()
1494 .fill(move |d: &f64, _, _, _| if *d >= 0. { gain } else { loss })
1495 .bar_color(&-2., frame, bounds, cx);
1496 let gradient = bars()
1497 .fill(move |_: &f64, _, _, _| {
1498 linear_gradient(
1499 0.,
1500 gpui_kit::linear_color_stop(gain, 0.),
1501 gpui_kit::linear_color_stop(loss, 1.),
1502 )
1503 })
1504 .bar_color(&1., frame, bounds, cx);
1505 let stops = bars()
1506 .fill_gradient(move |_: &f64, _, _| {
1507 [
1508 gpui_kit::linear_color_stop(gain, 0.),
1509 gpui_kit::linear_color_stop(loss, 1.),
1510 ]
1511 })
1512 .bar_color(&1., frame, bounds, cx);
1513 (default, solid, gradient, stops)
1514 });
1515 let accent = cx.update(|cx| cx.macos().chart[0]);
1516
1517 assert_eq!(default, accent);
1518 assert_eq!(solid, gpui_kit::red());
1519 assert_eq!(gradient, accent);
1520 assert_eq!(stops, gpui_kit::green());
1521 }
1522
1523 #[test]
1526 fn the_tooltip_reads_the_painted_bar_frame() {
1527 let bars = BarChart::new([1., -2.])
1528 .band(|d: &f64| SharedString::from(format!("{d}")))
1529 .value(|d: &f64| *d);
1530 let bounds = Bounds::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)));
1531 let band_scale = bars.band_scale(bounds).expect("bars have a band scale");
1532 let up = bars.bar_frame(&1., &band_scale, bounds).expect("a frame");
1533 let down = bars.bar_frame(&-2., &band_scale, bounds).expect("a frame");
1534
1535 assert_eq!(up.origin.y + up.size.height, down.origin.y);
1537 assert!((down.size.height - 2. * up.size.height).abs() < 0.01);
1538 assert!(up.origin.x < down.origin.x);
1539 assert_eq!(up.size.width, band_scale.band_width());
1540 }
1541}