1#![allow(clippy::type_complexity)]
2
3mod area_chart;
4mod bar_chart;
5mod candlestick_chart;
6mod line_chart;
7mod pie_chart;
8mod radar_chart;
9mod sankey_chart;
10
11pub use area_chart::AreaChart;
12pub use bar_chart::BarChart;
13pub use candlestick_chart::CandlestickChart;
14pub use line_chart::LineChart;
15pub use pie_chart::PieChart;
16pub use radar_chart::{RadarChart, RadarLabel};
17pub use sankey_chart::{SankeyChart, SankeyLabel};
18
19pub use crate::plot::shape::{BarAlignment, SankeyAlign, SankeyLink, SankeyValueScale};
20
21use std::{hash::Hash, panic::Location, rc::Rc};
22
23use gpui_kit::{
24 AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels,
25 SharedString, Size, TextAlign, Window, point, px,
26};
27
28use crate::{
29 ActiveTheme,
30 plot::{
31 AxisLabelPlacement, AxisText, Grid, PlotLabel,
32 label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
33 scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint},
34 tooltip::Tooltip,
35 },
36};
37
38pub(crate) fn slice_spring() -> gpui_kit::base::Spring {
40 gpui_kit::base::Spring::new(std::time::Duration::from_millis(140))
41}
42
43#[track_caller]
58pub(crate) fn caller_id() -> ElementId {
59 ElementId::CodeLocation(*Location::caller())
60}
61
62pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.);
64
65pub(crate) const HOVER_HALO_SIZE: Pixels = px(12.);
71
72pub(crate) fn axis_point_count(point_count: Option<usize>, data_len: usize) -> usize {
76 point_count.unwrap_or(data_len).max(data_len)
77}
78
79pub(crate) fn point_range(start: f32, width: f32, data_len: usize, point_count: usize) -> [f32; 2] {
84 let end = if point_count > 1 {
85 width * data_len.saturating_sub(1) as f32 / (point_count - 1) as f32
86 } else {
87 width
88 };
89 [start, start + end]
90}
91
92#[derive(Clone, Copy)]
95pub(crate) struct ValueExtent {
96 lo: f64,
97 hi: f64,
98 bottom: f32,
99 top: f32,
100}
101
102impl ValueExtent {
103 pub(crate) fn value_at(&self, y: f32) -> f64 {
105 if self.bottom == self.top {
106 return self.lo;
107 }
108 self.lo + (self.hi - self.lo) * ((self.bottom - y) / (self.bottom - self.top)) as f64
109 }
110
111 pub(crate) fn position_of(&self, value: f64) -> Option<f32> {
113 if self.hi == self.lo {
114 return None;
115 }
116 Some(
117 self.bottom
118 - ((value - self.lo) / (self.hi - self.lo)) as f32 * (self.bottom - self.top),
119 )
120 }
121}
122
123pub(crate) fn point_value_scale<Y>(
129 values: impl IntoIterator<Item = Y>,
130 domain: Option<(Y, Y)>,
131 height: f32,
132 (top, bottom): (f32, f32),
133) -> (ScaleLinear<Y>, ValueExtent)
134where
135 Y: PlotValue,
136{
137 let (min, max) = match domain {
138 Some((min, max)) if min > max => (max, min),
139 Some(domain) => domain,
140 None => values
141 .into_iter()
142 .fold((Y::zero(), Y::zero()), |(min, max), value| {
143 (
144 if value < min { value } else { min },
145 if value > max { value } else { max },
146 )
147 }),
148 };
149 let extent = ValueExtent {
150 lo: min.to_f64().unwrap_or(0.),
151 hi: max.to_f64().unwrap_or(0.),
152 bottom: height - bottom,
153 top,
154 };
155 (ScaleLinear::new([min, max], [height - bottom, top]), extent)
156}
157
158pub(crate) const AXIS_GAP: f32 = TEXT_SIZE + TEXT_GAP * 4.;
162
163pub(crate) const MAX_BAND_WIDTH: f32 = 30.;
166
167pub(crate) const VALUE_AXIS_GAP: f32 = 32.;
170
171pub(crate) fn value_axis_gap(
177 labels: impl IntoIterator<Item = SharedString>,
178 window: &mut Window,
179) -> f32 {
180 labels
181 .into_iter()
182 .map(|label| measure_text_width(&label, px(TEXT_SIZE), window) + TEXT_GAP * 2.)
183 .fold(VALUE_AXIS_GAP, f32::max)
184}
185
186pub(crate) type TickFormat = Rc<dyn Fn(f64) -> SharedString>;
188
189pub(crate) fn format_tick(value: f64) -> SharedString {
191 if (value - value.round()).abs() < 0.001 {
192 format!("{:.0}", value).into()
193 } else {
194 format!("{:.1}", value).into()
195 }
196}
197
198pub(crate) fn labeled_items(
201 len: usize,
202 label_count: Option<usize>,
203 tick_margin: usize,
204) -> Vec<bool> {
205 match label_count {
206 Some(count) => {
207 let mut labeled = vec![false; len];
208 match count {
209 0 => {}
210 1 => labeled.iter_mut().take(1).for_each(|l| *l = true),
211 count if count >= len => labeled.iter_mut().for_each(|l| *l = true),
212 count => {
213 for k in 0..count {
214 let ix =
215 (k as f32 * (len - 1) as f32 / (count - 1) as f32).round() as usize;
216 labeled[ix] = true;
217 }
218 }
219 }
220 labeled
221 }
222 None => (0..len)
223 .map(|i| (i + 1) % tick_margin.max(1) == 0)
224 .collect(),
225 }
226}
227
228pub(crate) struct TooltipContent<T> {
232 title: Option<Rc<dyn Fn(&T) -> SharedString>>,
233 value: Option<Rc<dyn Fn(&T, usize, f64) -> SharedString>>,
234 value_color: Option<Rc<dyn Fn(&T, usize, f64) -> Hsla>>,
235 content: Option<Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>>,
236}
237
238impl<T> Default for TooltipContent<T> {
239 fn default() -> Self {
240 Self {
241 title: None,
242 value: None,
243 value_color: None,
244 content: None,
245 }
246 }
247}
248
249impl<T: 'static> TooltipContent<T> {
250 pub(crate) fn set_title(&mut self, title: impl Fn(&T) -> SharedString + 'static) {
251 self.title = Some(Rc::new(title));
252 }
253
254 pub(crate) fn set_value(&mut self, value: impl Fn(&T, usize, f64) -> SharedString + 'static) {
255 self.value = Some(Rc::new(value));
256 }
257
258 pub(crate) fn set_value_color<H: Into<Hsla>>(
259 &mut self,
260 color: impl Fn(&T, usize, f64) -> H + 'static,
261 ) {
262 self.value_color = Some(Rc::new(move |d, ix, value| color(d, ix, value).into()));
263 }
264
265 pub(crate) fn set_content<E: IntoElement>(
266 &mut self,
267 content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
268 ) {
269 self.content = Some(Rc::new(move |d, window, cx| {
270 content(d, window, cx).into_any_element()
271 }));
272 }
273
274 fn title_text(&self, d: &T, fallback: Option<SharedString>) -> Option<SharedString> {
277 match self.title.as_ref() {
278 Some(title) => Some(title(d)),
279 None => fallback,
280 }
281 }
282
283 fn value_text(&self, d: &T, ix: usize, value: f64) -> SharedString {
285 match self.value.as_ref() {
286 Some(text) => text(d, ix, value),
287 None => format!("{}", value).into(),
288 }
289 }
290
291 pub(crate) fn apply<R>(
296 &self,
297 tooltip: Tooltip,
298 d: &T,
299 title: impl FnOnce() -> Option<SharedString>,
300 rows: impl FnOnce() -> Option<R>,
301 window: &mut Window,
302 cx: &mut App,
303 ) -> Option<Tooltip>
304 where
305 R: IntoIterator<Item = (Hsla, SharedString, f64)>,
306 {
307 if let Some(content) = self.content.as_ref() {
308 return Some(tooltip.child(content(d, window, cx)));
309 }
310 let mut tooltip = match self.title_text(d, title()) {
311 Some(title) => tooltip.title(title),
312 None => tooltip,
313 };
314 for (ix, (swatch, name, value)) in rows()?.into_iter().enumerate() {
315 tooltip = tooltip.row(swatch, name, self.value_text(d, ix, value));
316 if let Some(color) = self.value_color.as_ref() {
317 tooltip = tooltip.value_color(color(d, ix, value));
318 }
319 }
320 Some(tooltip)
321 }
322}
323
324pub(crate) struct PointAxes {
327 pub(crate) y_axis: bool,
328 pub(crate) y_axis_label_placement: AxisLabelPlacement,
329 pub(crate) y_tick_count: usize,
330 pub(crate) y_tick_format: Option<TickFormat>,
331 pub(crate) x_tick_count: Option<usize>,
332 pub(crate) grid_columns: usize,
333 pub(crate) grid_dashed: bool,
334 pub(crate) y_padding: (f32, f32),
335 pub(crate) reference_lines: Vec<f64>,
336 y_label_gap: f32,
338}
339
340impl Default for PointAxes {
341 fn default() -> Self {
342 Self {
343 y_axis: false,
344 y_axis_label_placement: AxisLabelPlacement::default(),
345 y_tick_count: 5,
346 y_tick_format: None,
347 x_tick_count: None,
348 grid_columns: 0,
349 grid_dashed: false,
350 y_padding: (10., 0.),
351 reference_lines: vec![],
352 y_label_gap: VALUE_AXIS_GAP,
353 }
354 }
355}
356
357impl PointAxes {
358 pub(crate) fn plot_left(&self) -> f32 {
361 if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
362 self.y_label_gap
363 } else {
364 0.
365 }
366 }
367
368 pub(crate) fn measure_y_labels(
371 &mut self,
372 extent: ValueExtent,
373 height: f32,
374 window: &mut Window,
375 ) {
376 if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
377 let labels = self
378 .y_tick_labels(extent, height)
379 .into_iter()
380 .map(|(_, text)| text);
381 self.y_label_gap = value_axis_gap(labels, window);
382 }
383 }
384
385 fn y_tick_labels(&self, extent: ValueExtent, height: f32) -> Vec<(f32, SharedString)> {
387 self.tick_positions(height)
388 .into_iter()
389 .map(|y| {
390 let value = extent.value_at(y);
391 let text = match self.y_tick_format.as_ref() {
392 Some(format) => format(value),
393 None => format_tick(value),
394 };
395 (y, text)
396 })
397 .collect()
398 }
399
400 pub(crate) fn plot_bounds(&self, bounds: Bounds<Pixels>, height: f32) -> Bounds<Pixels> {
403 let left = self.plot_left();
404 Bounds {
405 origin: bounds.origin + point(px(left), px(0.)),
406 size: Size::new(bounds.size.width - px(left), px(height)),
407 }
408 }
409
410 fn tick_positions(&self, height: f32) -> Vec<f32> {
413 let count = self.y_tick_count.max(2);
414 (0..count)
415 .map(|i| height * i as f32 / (count - 1) as f32)
416 .collect()
417 }
418
419 pub(crate) fn paint_grid(
422 &self,
423 bounds: Bounds<Pixels>,
424 height: f32,
425 window: &mut Window,
426 cx: &mut App,
427 ) {
428 let plot = self.plot_bounds(bounds, height);
429 let mut rows = self.tick_positions(height);
430 rows.pop();
431 let width = plot.size.width.as_f32();
432 let columns: Vec<f32> = (0..self.grid_columns)
433 .map(|i| width * i as f32 / self.grid_columns as f32)
434 .collect();
435 let grid = Grid::new().y(rows).x(columns).stroke(cx.macos().divider());
436 let grid = if self.grid_dashed {
437 grid.dash_array(&[px(4.), px(2.)])
438 } else {
439 grid
440 };
441 grid.paint(&plot, window);
442 }
443
444 pub(crate) fn paint_reference_lines(
447 &self,
448 extent: ValueExtent,
449 bounds: Bounds<Pixels>,
450 height: f32,
451 window: &mut Window,
452 cx: &mut App,
453 ) {
454 let rows: Vec<f32> = self
455 .reference_lines
456 .iter()
457 .filter_map(|v| extent.position_of(*v))
458 .filter(|y| (0. ..=height).contains(y))
459 .collect();
460 if rows.is_empty() {
461 return;
462 }
463 Grid::new()
464 .y(rows)
465 .stroke(cx.macos().secondary)
466 .dash_array(&[px(4.), px(2.)])
467 .paint(&self.plot_bounds(bounds, height), window);
468 }
469
470 pub(crate) fn paint_y_labels(
472 &self,
473 extent: ValueExtent,
474 bounds: Bounds<Pixels>,
475 height: f32,
476 window: &mut Window,
477 cx: &mut App,
478 ) {
479 if !self.y_axis {
480 return;
481 }
482 let color = cx.macos().secondary;
483 let labels = self
484 .y_tick_labels(extent, height)
485 .into_iter()
486 .map(|(y, text)| {
487 match self.y_axis_label_placement {
488 AxisLabelPlacement::Inside => {
491 let top = if y < TEXT_HEIGHT {
492 y + TEXT_GAP
493 } else {
494 y - TEXT_HEIGHT
495 };
496 Text::new(text, point(TEXT_GAP, top), color)
497 }
498 AxisLabelPlacement::Outside => {
499 let top = (y - TEXT_SIZE / 2.).clamp(0., (height - TEXT_SIZE).max(0.));
500 Text::new(text, point(self.y_label_gap - TEXT_GAP * 2., top), color)
501 .align(TextAlign::Right)
502 }
503 }
504 })
505 .collect();
506 PlotLabel::new(labels).paint(&bounds, window, cx);
507 }
508}
509
510pub(crate) fn pinned_plot_mask(bounds: Bounds<Pixels>, height: f32) -> ContentMask<Pixels> {
515 let bleed = HOVER_DOT_SIZE / 2.;
516 ContentMask {
517 bounds: Bounds::from_corners(
518 bounds.origin - gpui_kit::point(bleed, bleed),
519 gpui_kit::point(bounds.right() + bleed, bounds.top() + px(height) + bleed),
520 ),
521 }
522}
523
524pub(crate) fn build_point_x_labels<T, X>(
530 data: &[T],
531 x_fn: &dyn Fn(&T) -> X,
532 x_scale: &ScalePoint<X>,
533 point_count: usize,
534 labeled: &[bool],
535 color: Hsla,
536) -> Vec<AxisText>
537where
538 X: PartialEq + Into<SharedString>,
539{
540 data.iter()
541 .enumerate()
542 .filter_map(|(i, d)| {
543 if !labeled.get(i).copied().unwrap_or(false) {
544 return None;
545 }
546 x_scale.tick_at(i).map(|x_tick| {
547 let align = match i {
548 0 if point_count == 1 => TextAlign::Center,
549 0 => TextAlign::Left,
550 i if i == point_count - 1 => TextAlign::Right,
551 _ => TextAlign::Center,
552 };
553 AxisText::new(x_fn(d).into(), x_tick, color).align(align)
554 })
555 })
556 .collect()
557}
558
559pub(crate) fn build_band_labels<T, X>(
566 data: &[T],
567 x_fn: &dyn Fn(&T) -> X,
568 x_scale: &ScaleBand<X>,
569 band_width: f32,
570 labeled: &[bool],
571 color: Hsla,
572) -> Vec<AxisText>
573where
574 X: Eq + Hash + Into<SharedString>,
575{
576 data.iter()
577 .enumerate()
578 .filter_map(|(i, d)| {
579 if !labeled.get(i).copied().unwrap_or(false) {
580 return None;
581 }
582 let label = x_fn(d);
583 x_scale.tick(&label).map(|x_tick| {
584 AxisText::new(label.into(), x_tick + band_width / 2., color)
585 .align(TextAlign::Center)
586 })
587 })
588 .collect()
589}
590
591#[cfg(test)]
592mod tests {
593 use std::cell::Cell;
594
595 use gpui_kit::{Hsla, SharedString, TestAppContext, blue, div, green, point, px, red, size};
596
597 use super::TooltipContent;
598 use crate::{
599 chart::PieChart,
600 plot::{Plot, tooltip::Tooltip},
601 };
602
603 fn chart() -> PieChart<f32> {
604 PieChart::new([1., 2.])
605 }
606
607 #[test]
610 fn a_chart_is_interactive_without_being_given_an_id() {
611 assert!(Plot::id(&chart()).is_some());
612 }
613
614 #[test]
616 fn charts_built_at_different_sites_get_different_ids() {
617 assert_ne!(
618 Plot::id(&PieChart::new([1.])),
619 Plot::id(&PieChart::new([1.]))
620 );
621 }
622
623 #[test]
625 fn charts_built_at_one_site_share_an_id() {
626 assert_eq!(Plot::id(&chart()), Plot::id(&chart()));
627 }
628
629 #[test]
632 fn a_chart_turned_off_has_no_id_to_key_anything_on() {
633 assert!(Plot::id(&chart().interactive(false)).is_none());
634 assert!(Plot::id(&chart().id("pie").interactive(false)).is_none());
635 }
636
637 #[test]
640 fn only_the_last_point_right_aligns_its_label() {
641 use gpui_kit::{Hsla, TextAlign};
642
643 use super::{build_point_x_labels, point_range};
644 use crate::plot::scale::ScalePoint;
645
646 let data = ["a", "b", "c"];
647 let align = |point_count| {
648 let x = ScalePoint::new(
649 data.to_vec(),
650 point_range(0., 100., data.len(), point_count),
651 );
652 build_point_x_labels(
653 &data,
654 &|d: &&'static str| *d,
655 &x,
656 point_count,
657 &[true; 3],
658 Hsla::default(),
659 )
660 .into_iter()
661 .map(|label| label.align)
662 .collect::<Vec<_>>()
663 };
664
665 assert_eq!(
666 align(3),
667 [TextAlign::Left, TextAlign::Center, TextAlign::Right]
668 );
669 assert_eq!(
670 align(5),
671 [TextAlign::Left, TextAlign::Center, TextAlign::Center]
672 );
673 }
674
675 #[test]
676 fn a_named_id_replaces_the_default() {
677 assert_eq!(
678 Plot::id(&chart().id("pie")),
679 Some(gpui_kit::ElementId::Name("pie".into()))
680 );
681 }
682
683 #[test]
686 fn the_default_ticks_keep_the_grid_in_place() {
687 let axes = super::PointAxes::default();
688 let mut rows = axes.tick_positions(100.);
689 rows.pop();
690 assert_eq!(rows, vec![0., 25., 50., 75.]);
691 }
692
693 #[test]
694 fn a_label_count_spreads_labels_from_the_first_item_to_the_last() {
695 use super::labeled_items;
696
697 let shown = |len, count| {
698 labeled_items(len, Some(count), 1)
699 .iter()
700 .enumerate()
701 .filter_map(|(i, &on)| on.then_some(i))
702 .collect::<Vec<_>>()
703 };
704 assert_eq!(shown(11, 3), vec![0, 5, 10]);
705 assert_eq!(shown(10, 2), vec![0, 9]);
706 assert_eq!(shown(3, 5), vec![0, 1, 2]);
707 assert_eq!(shown(4, 1), vec![0]);
708 assert!(shown(4, 0).is_empty());
709
710 assert_eq!(labeled_items(4, None, 2), vec![false, true, false, true]);
712 assert_eq!(labeled_items(4, None, 0), vec![true; 4]);
713 assert!(labeled_items(0, None, 0).is_empty());
714 }
715
716 #[test]
719 fn a_tick_reads_the_value_at_its_height() {
720 use super::point_value_scale;
721
722 let (_, extent) = point_value_scale([10., 20.], None, 110., (10., 0.));
723 assert_eq!(extent.value_at(110.), 0.);
724 assert_eq!(extent.value_at(10.), 20.);
725 assert!((extent.value_at(0.) - 22.).abs() < 1e-4);
726 assert_eq!(extent.position_of(20.), Some(10.));
727
728 let (_, extent) = point_value_scale([0.], Some((100., 200.)), 100., (0., 0.));
729 assert_eq!(extent.value_at(0.), 200.);
730 assert_eq!(extent.position_of(150.), Some(50.));
731
732 let (scale, extent) = point_value_scale([0.], Some((200., 100.)), 100., (0., 0.));
733 use crate::plot::scale::Scale;
734 assert_eq!(extent.position_of(150.), scale.tick(&150.));
735 assert_eq!(extent.value_at(0.), 200.);
736 }
737
738 #[derive(Clone)]
739 struct CountedCategory {
740 index: usize,
741 comparisons: std::rc::Rc<Cell<usize>>,
742 }
743
744 impl PartialEq for CountedCategory {
745 fn eq(&self, other: &Self) -> bool {
746 self.comparisons.set(self.comparisons.get() + 1);
747 self.index == other.index
748 }
749 }
750
751 impl From<CountedCategory> for SharedString {
752 fn from(value: CountedCategory) -> Self {
753 value.index.to_string().into()
754 }
755 }
756
757 struct LargePointChart {
758 area: bool,
759 comparisons: std::rc::Rc<Cell<usize>>,
760 value_reads: std::rc::Rc<Cell<usize>>,
761 }
762
763 impl gpui_kit::Render for LargePointChart {
764 fn render(
765 &mut self,
766 _: &mut gpui_kit::Window,
767 _: &mut gpui_kit::Context<Self>,
768 ) -> impl gpui_kit::IntoElement {
769 use gpui_kit::{IntoElement, ParentElement, Styled};
770 let comparisons = self.comparisons.clone();
771 let x = move |&(index, _): &(usize, f64)| CountedCategory {
772 index,
773 comparisons: comparisons.clone(),
774 };
775 let data = (0..2048).map(|index| (index, (index % 17 + 1) as f64));
776 let value_reads = self.value_reads.clone();
777 let y = move |d: &(usize, f64)| {
778 value_reads.set(value_reads.get() + 1);
779 d.1
780 };
781 let chart = if self.area {
782 super::AreaChart::new(data)
783 .x(x)
784 .y(y)
785 .x_tick_count(8)
786 .into_any_element()
787 } else {
788 super::LineChart::new(data)
789 .x(x)
790 .y(y)
791 .x_tick_count(8)
792 .into_any_element()
793 };
794 div().w(px(640.)).h(px(320.)).child(chart)
795 }
796 }
797
798 #[gpui_kit::test]
799 fn point_chart_rendering_does_not_search_the_domain_per_point(cx: &mut TestAppContext) {
800 cx.update(crate::init);
801 for area in [false, true] {
802 let comparisons = std::rc::Rc::new(Cell::new(0));
803 let value_reads = std::rc::Rc::new(Cell::new(0));
804 let (_, cx) = cx.add_window_view({
805 let comparisons = comparisons.clone();
806 let value_reads = value_reads.clone();
807 move |_, _| LargePointChart {
808 area,
809 comparisons,
810 value_reads,
811 }
812 });
813 cx.update(|window, cx| window.draw(cx).clear(cx));
814 assert!(
815 value_reads.get() >= 2048,
816 "The regression check must exercise the complete series"
817 );
818 assert_eq!(
819 comparisons.get(),
820 0,
821 "Point positions must be projected by index in linear time"
822 );
823 }
824 }
825
826 #[gpui_kit::test]
827 fn repeated_point_labels_keep_distinct_positions_and_hover_targets(cx: &mut TestAppContext) {
828 use crate::plot::scale::ScalePoint;
829 cx.update(|cx| {
830 crate::init(cx);
831 let bounds = gpui_kit::Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
832 let line = super::LineChart::new([1., 2., 3.])
833 .x(|_| "Repeated")
834 .y(|d| *d)
835 .x_axis(false);
836 let area = super::AreaChart::new([1., 2., 3.])
837 .x(|_| "Repeated")
838 .y(|d| *d)
839 .x_axis(false);
840 for state in [
841 line.tooltip_state(point(px(100.), px(50.)), bounds, cx),
842 area.tooltip_state(point(px(100.), px(50.)), bounds, cx),
843 ] {
844 let state = state.unwrap();
845 assert_eq!(state.index, 2);
846 assert_eq!(state.cross_line.x, px(100.));
847 assert_eq!(state.dots[0].x, px(100.));
848 }
849 let data = ["Repeated"; 3];
850 let scale = ScalePoint::new(data, [0., 100.]);
851 let labels =
852 super::build_point_x_labels(&data, &|d| *d, &scale, 3, &[true; 3], Hsla::default());
853 assert_eq!(
854 labels.iter().map(|label| label.tick).collect::<Vec<_>>(),
855 [px(0.), px(50.), px(100.)]
856 );
857 });
858 }
859
860 #[test]
863 fn tooltip_text_falls_back_to_the_chart_own() {
864 let mut content = TooltipContent::<f64>::default();
865 assert_eq!(
866 content.title_text(&1., Some("Jan".into())),
867 Some("Jan".into())
868 );
869 assert_eq!(content.title_text(&1., None), None);
870 assert_eq!(content.value_text(&1., 0, 1234.5).as_ref(), "1234.5");
871
872 content.set_title(|d| format!("Day {d}").into());
873 content.set_value(|_, _, value| format!("${value:.2}").into());
874 assert_eq!(content.title_text(&3., None), Some("Day 3".into()));
875 assert_eq!(content.value_text(&3., 0, 1234.5).as_ref(), "$1234.50");
876 }
877
878 #[gpui_kit::test]
881 fn tooltip_fill_writes_each_row_with_the_value_color(cx: &mut TestAppContext) {
882 let mut content = TooltipContent::<f64>::default();
883 content.set_value(|_, ix, value| format!("{ix}: {value:+}").into());
884 content.set_value_color(|_, _, value| if value >= 0. { green() } else { red() });
885 let cx = cx.add_empty_window();
886 let tooltip = cx
887 .update(|window, cx| {
888 content.apply(
889 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
890 &1.,
891 || Some("Jan".into()),
892 || Some([(blue(), "Open".into(), 2.), (blue(), "Close".into(), -1.)]),
893 window,
894 cx,
895 )
896 })
897 .expect("rows are given");
898
899 assert_eq!(tooltip.title_for_test().map(|t| t.as_ref()), Some("Jan"));
900 assert_eq!(
901 tooltip.rows_for_test(),
902 vec![
903 ("0: +2".into(), Some(green())),
904 ("1: -1".into(), Some(red()))
905 ]
906 );
907 }
908
909 #[gpui_kit::test]
912 fn tooltip_fill_leaves_the_title_off_without_one(cx: &mut TestAppContext) {
913 let content = TooltipContent::<f64>::default();
914 let cx = cx.add_empty_window();
915 let tooltip = cx
916 .update(|window, cx| {
917 content.apply(
918 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
919 &1.,
920 || None,
921 || Some([(blue(), "Alpha".into(), 80.)]),
922 window,
923 cx,
924 )
925 })
926 .expect("rows are given");
927
928 assert!(tooltip.title_for_test().is_none());
929 assert_eq!(tooltip.rows_for_test(), vec![("80".into(), None)]);
930 }
931
932 #[gpui_kit::test]
935 fn tooltip_fill_renders_the_caller_content_without_building_rows(cx: &mut TestAppContext) {
936 let mut content = TooltipContent::<f64>::default();
937 content.set_title(|_| "Caller".into());
938 content.set_content(|_, _, _| div());
939 let built = Cell::new(false);
940 let cx = cx.add_empty_window();
941 let tooltip = cx.update(|window, cx| {
942 content.apply(
943 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
944 &1.,
945 || {
946 built.set(true);
947 Some("Jan".into())
948 },
949 || -> Option<[(Hsla, SharedString, f64); 0]> {
950 built.set(true);
951 None
952 },
953 window,
954 cx,
955 )
956 });
957
958 let tooltip = tooltip.expect("the caller renders");
959 assert!(!built.get());
960 assert!(tooltip.title_for_test().is_none());
961 assert!(tooltip.rows_for_test().is_empty());
962 }
963}