Skip to main content

gpui_component/chart/
mod.rs

1mod area_chart;
2mod bar_chart;
3mod candlestick_chart;
4mod line_chart;
5mod pie_chart;
6mod radar_chart;
7mod sankey_chart;
8
9pub use area_chart::AreaChart;
10pub use bar_chart::BarChart;
11pub use candlestick_chart::CandlestickChart;
12pub use line_chart::LineChart;
13pub use pie_chart::PieChart;
14pub use radar_chart::{RadarChart, RadarLabel};
15pub use sankey_chart::{SankeyChart, SankeyLabel};
16
17use std::{hash::Hash, panic::Location, rc::Rc};
18
19use gpui::{
20    AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels,
21    SharedString, Size, TextAlign, Window, point, px,
22};
23
24use crate::{
25    ActiveTheme,
26    plot::{
27        AxisLabelPlacement, AxisText, Grid, PlotLabel,
28        label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
29        scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint},
30        tooltip::Tooltip,
31    },
32};
33
34/// The [`ElementId`] a chart carries when the caller names none: the source
35/// location it was constructed at.
36///
37/// The crosshair, the hover lift, the tooltip and the path cache all need an id
38/// unique among siblings, and a chart that has to be handed one per call site is
39/// a chart every caller leaves static. One construction site written out once,
40/// which is nearly every chart, is unique by construction.
41///
42/// The exception is one site rendering several charts as siblings, where every
43/// copy shares this location and therefore one hover state and one path cache.
44/// A `GlobalElementId` is the whole id stack, so rows that carry their own id —
45/// which `List` and `uniform_list` give them — already separate the copies
46/// underneath them; only id-less siblings collide, and those name an id with
47/// `id`. GPUI takes this same trade-off for [`gpui::Window::use_state`].
48#[track_caller]
49pub(crate) fn caller_id() -> ElementId {
50    ElementId::CodeLocation(*Location::caller())
51}
52
53/// The size of the dot marking the hovered data point.
54pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.);
55
56/// The ring behind the hovered dot at full focus; a [`Tooltip`] grows it out
57/// of the dot as the hover fades in.
58///
59/// [`Tooltip`]: crate::plot::tooltip::Tooltip
60pub(crate) const HOVER_HALO_SIZE: Pixels = px(20.);
61
62/// How many points the x axis of a point chart (`LineChart`, `AreaChart`) is
63/// laid out for: `point_count`, or the data's own length when that is unset or
64/// smaller.
65pub(crate) fn axis_point_count(point_count: Option<usize>, data_len: usize) -> usize {
66    point_count.unwrap_or(data_len).max(data_len)
67}
68
69/// The x range a point scale spreads `data_len` points over, when the axis is
70/// laid out for `point_count` of them across `width` pixels from `start`.
71///
72/// The data takes the leading points, so each keeps its place as the data grows.
73pub(crate) fn point_range(start: f32, width: f32, data_len: usize, point_count: usize) -> [f32; 2] {
74    let end = if point_count > 1 {
75        width * data_len.saturating_sub(1) as f32 / (point_count - 1) as f32
76    } else {
77        width
78    };
79    [start, start + end]
80}
81
82/// The value range a y scale spans and the pixel range it maps onto, kept in
83/// `f64` so tick labels can read the value at any height of the plot.
84#[derive(Clone, Copy)]
85pub(crate) struct ValueExtent {
86    lo: f64,
87    hi: f64,
88    bottom: f32,
89    top: f32,
90}
91
92impl ValueExtent {
93    /// The value the scale puts at pixel `y`.
94    pub(crate) fn value_at(&self, y: f32) -> f64 {
95        if self.bottom == self.top {
96            return self.lo;
97        }
98        self.lo + (self.hi - self.lo) * ((self.bottom - y) / (self.bottom - self.top)) as f64
99    }
100
101    /// The pixel the scale puts `value` at, or `None` for a scale with no extent.
102    pub(crate) fn position_of(&self, value: f64) -> Option<f32> {
103        if self.hi == self.lo {
104            return None;
105        }
106        Some(
107            self.bottom
108                - ((value - self.lo) / (self.hi - self.lo)) as f32 * (self.bottom - self.top),
109        )
110    }
111}
112
113/// The y scale of a point chart, from `height` less the bottom `padding` up to
114/// the top `padding`.
115///
116/// A pinned `domain` maps its ends onto that range; otherwise the scale fits
117/// `values` from zero.
118pub(crate) fn point_value_scale<Y>(
119    values: impl IntoIterator<Item = Y>,
120    domain: Option<(Y, Y)>,
121    height: f32,
122    (top, bottom): (f32, f32),
123) -> (ScaleLinear<Y>, ValueExtent)
124where
125    Y: PlotValue,
126{
127    let domain: Vec<Y> = match domain {
128        Some((min, max)) => vec![min, max],
129        None => values.into_iter().chain(Some(Y::zero())).collect(),
130    };
131    let (lo, hi) = domain
132        .iter()
133        .filter_map(|v| v.to_f64())
134        .fold((f64::MAX, f64::MIN), |(lo, hi), v| (lo.min(v), hi.max(v)));
135    let extent = ValueExtent {
136        lo,
137        hi,
138        bottom: height - bottom,
139        top,
140    };
141    (ScaleLinear::new(domain, [height - bottom, top]), extent)
142}
143
144/// The height the charts reserve under the plot for x-axis labels, which they
145/// draw at the default [`TEXT_SIZE`]: [`axis_gutter`](crate::plot::axis_gutter)
146/// for that size.
147pub(crate) const AXIS_GAP: f32 = TEXT_SIZE + TEXT_GAP * 4.;
148
149/// The widest a bar or candle is by default, in pixels, however few bands
150/// share the width; see `BarChart::max_band_width`.
151pub(crate) const MAX_BAND_WIDTH: f32 = 30.;
152
153/// The least space kept beside the plot for value-axis tick labels drawn
154/// outside it, in pixels; wider labels widen it (see [`value_axis_gap`]).
155pub(crate) const VALUE_AXIS_GAP: f32 = 32.;
156
157/// The gutter value-axis tick `labels` drawn outside the plot need: the widest
158/// label and the gap before the plot, and never less than [`VALUE_AXIS_GAP`].
159///
160/// Measured in `prepaint`, which runs before `tooltip_state` and `paint`, so
161/// hit-testing and painting share one gutter.
162pub(crate) fn value_axis_gap(
163    labels: impl IntoIterator<Item = SharedString>,
164    window: &mut Window,
165) -> f32 {
166    labels
167        .into_iter()
168        .map(|label| measure_text_width(&label, px(TEXT_SIZE), window) + TEXT_GAP * 2.)
169        .fold(VALUE_AXIS_GAP, f32::max)
170}
171
172/// A caller's tick label text for a value.
173pub(crate) type TickFormat = Rc<dyn Fn(f64) -> SharedString>;
174
175/// The default tick label: whole numbers bare, the rest to one decimal.
176pub(crate) fn format_tick(value: f64) -> SharedString {
177    if (value - value.round()).abs() < 0.001 {
178        format!("{:.0}", value).into()
179    } else {
180        format!("{:.1}", value).into()
181    }
182}
183
184/// Which of `len` items carry a category label: `label_count` of them evenly
185/// spread from the first to the last when set, otherwise every `tick_margin`-th.
186pub(crate) fn labeled_items(
187    len: usize,
188    label_count: Option<usize>,
189    tick_margin: usize,
190) -> Vec<bool> {
191    match label_count {
192        Some(count) => {
193            let mut labeled = vec![false; len];
194            match count {
195                0 => {}
196                1 => labeled.iter_mut().take(1).for_each(|l| *l = true),
197                count if count >= len => labeled.iter_mut().for_each(|l| *l = true),
198                count => {
199                    for k in 0..count {
200                        let ix =
201                            (k as f32 * (len - 1) as f32 / (count - 1) as f32).round() as usize;
202                        labeled[ix] = true;
203                    }
204                }
205            }
206            labeled
207        }
208        None => (0..len).map(|i| (i + 1) % tick_margin == 0).collect(),
209    }
210}
211
212/// What a series chart (`LineChart`, `AreaChart`, `BarChart`, `RadarChart`,
213/// `CandlestickChart`) writes in its hover tooltip, and the builders the five
214/// charts forward to it.
215pub(crate) struct TooltipContent<T> {
216    title: Option<Rc<dyn Fn(&T) -> SharedString>>,
217    value: Option<Rc<dyn Fn(&T, usize, f64) -> SharedString>>,
218    value_color: Option<Rc<dyn Fn(&T, usize, f64) -> Hsla>>,
219    content: Option<Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>>,
220}
221
222impl<T> Default for TooltipContent<T> {
223    fn default() -> Self {
224        Self {
225            title: None,
226            value: None,
227            value_color: None,
228            content: None,
229        }
230    }
231}
232
233impl<T: 'static> TooltipContent<T> {
234    pub(crate) fn set_title(&mut self, title: impl Fn(&T) -> SharedString + 'static) {
235        self.title = Some(Rc::new(title));
236    }
237
238    pub(crate) fn set_value(&mut self, value: impl Fn(&T, usize, f64) -> SharedString + 'static) {
239        self.value = Some(Rc::new(value));
240    }
241
242    pub(crate) fn set_value_color<H: Into<Hsla>>(
243        &mut self,
244        color: impl Fn(&T, usize, f64) -> H + 'static,
245    ) {
246        self.value_color = Some(Rc::new(move |d, ix, value| color(d, ix, value).into()));
247    }
248
249    pub(crate) fn set_content<E: IntoElement>(
250        &mut self,
251        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
252    ) {
253        self.content = Some(Rc::new(move |d, window, cx| {
254            content(d, window, cx).into_any_element()
255        }));
256    }
257
258    /// The title for datum `d`: the caller's, or `fallback`, the chart's own,
259    /// which a chart may not have.
260    fn title_text(&self, d: &T, fallback: Option<SharedString>) -> Option<SharedString> {
261        match self.title.as_ref() {
262            Some(title) => Some(title(d)),
263            None => fallback,
264        }
265    }
266
267    /// The value text of row `ix`: the caller's, or the raw number.
268    fn value_text(&self, d: &T, ix: usize, value: f64) -> SharedString {
269        match self.value.as_ref() {
270            Some(text) => text(d, ix, value),
271            None => format!("{}", value).into(),
272        }
273    }
274
275    /// Write the content of `tooltip` for datum `d`: the caller's own content when it renders
276    /// one, otherwise the chart's `title`, if it has one, and one row per
277    /// `(swatch, name, value)`. Neither is built when the caller renders, and
278    /// `None` from `rows` means a row has no value to show.
279    pub(crate) fn apply<R>(
280        &self,
281        tooltip: Tooltip,
282        d: &T,
283        title: impl FnOnce() -> Option<SharedString>,
284        rows: impl FnOnce() -> Option<R>,
285        window: &mut Window,
286        cx: &mut App,
287    ) -> Option<Tooltip>
288    where
289        R: IntoIterator<Item = (Hsla, SharedString, f64)>,
290    {
291        if let Some(content) = self.content.as_ref() {
292            return Some(tooltip.child(content(d, window, cx)));
293        }
294        let mut tooltip = match self.title_text(d, title()) {
295            Some(title) => tooltip.title(title),
296            None => tooltip,
297        };
298        for (ix, (swatch, name, value)) in rows()?.into_iter().enumerate() {
299            tooltip = tooltip.row(swatch, name, self.value_text(d, ix, value));
300            if let Some(color) = self.value_color.as_ref() {
301                tooltip = tooltip.value_color(color(d, ix, value));
302            }
303        }
304        Some(tooltip)
305    }
306}
307
308/// The grid, value-axis labels and reference lines a point chart (`LineChart`,
309/// `AreaChart`) draws, and the builders both charts forward to it.
310pub(crate) struct PointAxes {
311    pub(crate) y_axis: bool,
312    pub(crate) y_axis_label_placement: AxisLabelPlacement,
313    pub(crate) y_tick_count: usize,
314    pub(crate) y_tick_format: Option<TickFormat>,
315    pub(crate) x_tick_count: Option<usize>,
316    pub(crate) grid_columns: usize,
317    pub(crate) grid_dashed: bool,
318    pub(crate) y_padding: (f32, f32),
319    pub(crate) reference_lines: Vec<f64>,
320    /// The value-axis gutter measured in `prepaint`; see [`value_axis_gap`].
321    y_label_gap: f32,
322}
323
324impl Default for PointAxes {
325    fn default() -> Self {
326        Self {
327            y_axis: false,
328            y_axis_label_placement: AxisLabelPlacement::default(),
329            y_tick_count: 5,
330            y_tick_format: None,
331            x_tick_count: None,
332            grid_columns: 0,
333            grid_dashed: true,
334            y_padding: (10., 0.),
335            reference_lines: vec![],
336            y_label_gap: VALUE_AXIS_GAP,
337        }
338    }
339}
340
341impl PointAxes {
342    /// Where the plot starts along x: past the value-axis gutter when the labels
343    /// sit outside it.
344    pub(crate) fn plot_left(&self) -> f32 {
345        if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
346            self.y_label_gap
347        } else {
348            0.
349        }
350    }
351
352    /// Measure the gutter the y labels need outside the plot, before the x
353    /// scale is laid out past it.
354    pub(crate) fn measure_y_labels(
355        &mut self,
356        extent: ValueExtent,
357        height: f32,
358        window: &mut Window,
359    ) {
360        if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
361            let labels = self
362                .y_tick_labels(extent, height)
363                .into_iter()
364                .map(|(_, text)| text);
365            self.y_label_gap = value_axis_gap(labels, window);
366        }
367    }
368
369    /// Each y tick's position and the label text for the value there.
370    fn y_tick_labels(&self, extent: ValueExtent, height: f32) -> Vec<(f32, SharedString)> {
371        self.tick_positions(height)
372            .into_iter()
373            .map(|y| {
374                let value = extent.value_at(y);
375                let text = match self.y_tick_format.as_ref() {
376                    Some(format) => format(value),
377                    None => format_tick(value),
378                };
379                (y, text)
380            })
381            .collect()
382    }
383
384    /// The plot area within `bounds`: past the value-axis gutter and above the
385    /// x axis at `height`.
386    pub(crate) fn plot_bounds(&self, bounds: Bounds<Pixels>, height: f32) -> Bounds<Pixels> {
387        let left = self.plot_left();
388        Bounds {
389            origin: bounds.origin + point(px(left), px(0.)),
390            size: Size::new(bounds.size.width - px(left), px(height)),
391        }
392    }
393
394    /// The y ticks, evenly spaced in pixels from the top edge (0) to the
395    /// baseline (`height`), both included.
396    fn tick_positions(&self, height: f32) -> Vec<f32> {
397        let count = self.y_tick_count.max(2);
398        (0..count)
399            .map(|i| height * i as f32 / (count - 1) as f32)
400            .collect()
401    }
402
403    /// Paint the grid: a line at every y tick but the baseline, which the x axis
404    /// draws, and `grid_columns` evenly spaced vertical lines from the left edge.
405    pub(crate) fn paint_grid(
406        &self,
407        bounds: Bounds<Pixels>,
408        height: f32,
409        window: &mut Window,
410        cx: &mut App,
411    ) {
412        let plot = self.plot_bounds(bounds, height);
413        let mut rows = self.tick_positions(height);
414        rows.pop();
415        let width = plot.size.width.as_f32();
416        let columns: Vec<f32> = (0..self.grid_columns)
417            .map(|i| width * i as f32 / self.grid_columns as f32)
418            .collect();
419        let grid = Grid::new().y(rows).x(columns).stroke(cx.theme().chart_grid);
420        let grid = if self.grid_dashed {
421            grid.dash_array(&[px(4.), px(2.)])
422        } else {
423            grid
424        };
425        grid.paint(&plot, window);
426    }
427
428    /// Paint a dashed line across the plot at each reference value, darker than
429    /// the grid so it reads apart from a dashed grid line.
430    pub(crate) fn paint_reference_lines(
431        &self,
432        extent: ValueExtent,
433        bounds: Bounds<Pixels>,
434        height: f32,
435        window: &mut Window,
436        cx: &mut App,
437    ) {
438        let rows: Vec<f32> = self
439            .reference_lines
440            .iter()
441            .filter_map(|v| extent.position_of(*v))
442            .filter(|y| (0. ..=height).contains(y))
443            .collect();
444        if rows.is_empty() {
445            return;
446        }
447        Grid::new()
448            .y(rows)
449            .stroke(cx.theme().muted_foreground)
450            .dash_array(&[px(4.), px(2.)])
451            .paint(&self.plot_bounds(bounds, height), window);
452    }
453
454    /// Paint a tick label at every y tick, reading the value the scale puts there.
455    pub(crate) fn paint_y_labels(
456        &self,
457        extent: ValueExtent,
458        bounds: Bounds<Pixels>,
459        height: f32,
460        window: &mut Window,
461        cx: &mut App,
462    ) {
463        if !self.y_axis {
464            return;
465        }
466        let color = cx.theme().muted_foreground;
467        let labels = self
468            .y_tick_labels(extent, height)
469            .into_iter()
470            .map(|(y, text)| {
471                match self.y_axis_label_placement {
472                    // Beside its grid line, above it but for the top one, which
473                    // would leave the plot.
474                    AxisLabelPlacement::Inside => {
475                        let top = if y < TEXT_HEIGHT {
476                            y + TEXT_GAP
477                        } else {
478                            y - TEXT_HEIGHT
479                        };
480                        Text::new(text, point(TEXT_GAP, top), color)
481                    }
482                    AxisLabelPlacement::Outside => {
483                        let top = (y - TEXT_SIZE / 2.).clamp(0., (height - TEXT_SIZE).max(0.));
484                        Text::new(text, point(self.y_label_gap - TEXT_GAP * 2., top), color)
485                            .align(TextAlign::Right)
486                    }
487                }
488            })
489            .collect();
490        PlotLabel::new(labels).paint(&bounds, window, cx);
491    }
492}
493
494/// The mask a point chart paints its series under once its y axis is pinned,
495/// so a value outside the pinned domain stops at the plot area instead of
496/// running over the x-axis labels. It bleeds by half a hover dot, keeping
497/// strokes and dots on the plot's edges whole.
498pub(crate) fn pinned_plot_mask(bounds: Bounds<Pixels>, height: f32) -> ContentMask<Pixels> {
499    let bleed = HOVER_DOT_SIZE / 2.;
500    ContentMask {
501        bounds: Bounds::from_corners(
502            bounds.origin - gpui::point(bleed, bleed),
503            gpui::point(bounds.right() + bleed, bounds.top() + px(height) + bleed),
504        ),
505    }
506}
507
508/// Build x-axis labels for point-based scales (`LineChart`, `AreaChart`).
509///
510/// Point scales place items at evenly spaced positions, on an axis laid out for
511/// `point_count` of them. A label on the first point is left-aligned, one on
512/// the last is right-aligned, and the rest are centered.
513pub(crate) fn build_point_x_labels<T, X>(
514    data: &[T],
515    x_fn: &dyn Fn(&T) -> X,
516    x_scale: &ScalePoint<X>,
517    point_count: usize,
518    labeled: &[bool],
519    color: Hsla,
520) -> Vec<AxisText>
521where
522    X: PartialEq + Into<SharedString>,
523{
524    data.iter()
525        .enumerate()
526        .filter_map(|(i, d)| {
527            if !labeled.get(i).copied().unwrap_or(false) {
528                return None;
529            }
530            x_scale.tick_at(i).map(|x_tick| {
531                let align = match i {
532                    0 if point_count == 1 => TextAlign::Center,
533                    0 => TextAlign::Left,
534                    i if i == point_count - 1 => TextAlign::Right,
535                    _ => TextAlign::Center,
536                };
537                AxisText::new(x_fn(d).into(), x_tick, color).align(align)
538            })
539        })
540        .collect()
541}
542
543/// Build axis labels for band-based scales (`BarChart`, `CandlestickChart`).
544///
545/// Band scales place items in evenly sized bands. The returned `tick`
546/// coordinate is the centre of each band along the band axis; the caller
547/// decides whether to feed the result to `PlotAxis::x_label` (vertical
548/// charts) or `PlotAxis::y_label` (horizontal charts).
549pub(crate) fn build_band_labels<T, X>(
550    data: &[T],
551    x_fn: &dyn Fn(&T) -> X,
552    x_scale: &ScaleBand<X>,
553    band_width: f32,
554    labeled: &[bool],
555    color: Hsla,
556) -> Vec<AxisText>
557where
558    X: Eq + Hash + Into<SharedString>,
559{
560    data.iter()
561        .enumerate()
562        .filter_map(|(i, d)| {
563            if !labeled.get(i).copied().unwrap_or(false) {
564                return None;
565            }
566            x_scale.tick(&x_fn(d)).map(|x_tick| {
567                // Call x_fn again to get an owned value for the label text.
568                AxisText::new(x_fn(d).into(), x_tick + band_width / 2., color)
569                    .align(TextAlign::Center)
570            })
571        })
572        .collect()
573}
574
575#[cfg(test)]
576mod tests {
577    use std::cell::Cell;
578
579    use gpui::{Hsla, SharedString, TestAppContext, blue, div, green, point, px, red, size};
580
581    use super::TooltipContent;
582    use crate::{
583        chart::PieChart,
584        plot::{Plot, tooltip::Tooltip},
585    };
586
587    fn chart() -> PieChart<f32> {
588        PieChart::new([1., 2.])
589    }
590
591    /// The whole point of the default: a chart nobody gave an id to is still
592    /// interactive, because every caller forgot to ask for it.
593    #[test]
594    fn a_chart_is_interactive_without_being_given_an_id() {
595        assert!(Plot::id(&chart()).is_some());
596    }
597
598    /// Two construction sites must not share hover state or a path cache.
599    #[test]
600    fn charts_built_at_different_sites_get_different_ids() {
601        assert_ne!(
602            Plot::id(&PieChart::new([1.])),
603            Plot::id(&PieChart::new([1.]))
604        );
605    }
606
607    /// One site reached twice is one id — the caveat `id` exists for.
608    #[test]
609    fn charts_built_at_one_site_share_an_id() {
610        assert_eq!(Plot::id(&chart()), Plot::id(&chart()));
611    }
612
613    /// The escape hatch: no id means no hitbox, so nothing above the chart has
614    /// to fight it for the cursor.
615    #[test]
616    fn a_chart_turned_off_has_no_id_to_key_anything_on() {
617        assert!(Plot::id(&chart().interactive(false)).is_none());
618        assert!(Plot::id(&chart().id("pie").interactive(false)).is_none());
619    }
620
621    /// Only a label on the axis's last point hugs the right edge; the last item
622    /// of data laid out for more points sits mid-axis and stays centered.
623    #[test]
624    fn only_the_last_point_right_aligns_its_label() {
625        use gpui::{Hsla, TextAlign};
626
627        use super::{build_point_x_labels, point_range};
628        use crate::plot::scale::ScalePoint;
629
630        let data = ["a", "b", "c"];
631        let align = |point_count| {
632            let x = ScalePoint::new(
633                data.to_vec(),
634                point_range(0., 100., data.len(), point_count),
635            );
636            build_point_x_labels(
637                &data,
638                &|d: &&'static str| *d,
639                &x,
640                point_count,
641                &[true; 3],
642                Hsla::default(),
643            )
644            .into_iter()
645            .map(|label| label.align)
646            .collect::<Vec<_>>()
647        };
648
649        assert_eq!(
650            align(3),
651            [TextAlign::Left, TextAlign::Center, TextAlign::Right]
652        );
653        assert_eq!(
654            align(5),
655            [TextAlign::Left, TextAlign::Center, TextAlign::Center]
656        );
657    }
658
659    #[test]
660    fn a_named_id_replaces_the_default() {
661        assert_eq!(
662            Plot::id(&chart().id("pie")),
663            Some(gpui::ElementId::Name("pie".into()))
664        );
665    }
666
667    /// The default five ticks put the grid where it always was: four lines
668    /// splitting the plot, the baseline left to the x axis.
669    #[test]
670    fn the_default_ticks_keep_the_grid_in_place() {
671        let axes = super::PointAxes::default();
672        let mut rows = axes.tick_positions(100.);
673        rows.pop();
674        assert_eq!(rows, vec![0., 25., 50., 75.]);
675    }
676
677    #[test]
678    fn a_label_count_spreads_labels_from_the_first_item_to_the_last() {
679        use super::labeled_items;
680
681        let shown = |len, count| {
682            labeled_items(len, Some(count), 1)
683                .iter()
684                .enumerate()
685                .filter_map(|(i, &on)| on.then_some(i))
686                .collect::<Vec<_>>()
687        };
688        assert_eq!(shown(11, 3), vec![0, 5, 10]);
689        assert_eq!(shown(10, 2), vec![0, 9]);
690        assert_eq!(shown(3, 5), vec![0, 1, 2]);
691        assert_eq!(shown(4, 1), vec![0]);
692        assert!(shown(4, 0).is_empty());
693
694        // Without a count the stride still decides.
695        assert_eq!(labeled_items(4, None, 2), vec![false, true, false, true]);
696    }
697
698    /// A tick label reads the value its height stands for, so the top one reads
699    /// past the highest value by the padding above it.
700    #[test]
701    fn a_tick_reads_the_value_at_its_height() {
702        use super::point_value_scale;
703
704        let (_, extent) = point_value_scale([10., 20.], None, 110., (10., 0.));
705        assert_eq!(extent.value_at(110.), 0.);
706        assert_eq!(extent.value_at(10.), 20.);
707        assert!((extent.value_at(0.) - 22.).abs() < 1e-4);
708        assert_eq!(extent.position_of(20.), Some(10.));
709
710        let (_, extent) = point_value_scale([0.], Some((100., 200.)), 100., (0., 0.));
711        assert_eq!(extent.value_at(0.), 200.);
712        assert_eq!(extent.position_of(150.), Some(50.));
713    }
714
715    /// Unset, the tooltip reads the chart's own title and the raw number;
716    /// set, the caller's text replaces both.
717    #[test]
718    fn tooltip_text_falls_back_to_the_chart_own() {
719        let mut content = TooltipContent::<f64>::default();
720        assert_eq!(
721            content.title_text(&1., Some("Jan".into())),
722            Some("Jan".into())
723        );
724        assert_eq!(content.title_text(&1., None), None);
725        assert_eq!(content.value_text(&1., 0, 1234.5).as_ref(), "1234.5");
726
727        content.set_title(|d| format!("Day {d}").into());
728        content.set_value(|_, _, value| format!("${value:.2}").into());
729        assert_eq!(content.title_text(&3., None), Some("Day 3".into()));
730        assert_eq!(content.value_text(&3., 0, 1234.5).as_ref(), "$1234.50");
731    }
732
733    /// Rows read the caller's value text and color, one per series, each told
734    /// which row it is.
735    #[gpui::test]
736    fn tooltip_fill_writes_each_row_with_the_value_color(cx: &mut TestAppContext) {
737        let mut content = TooltipContent::<f64>::default();
738        content.set_value(|_, ix, value| format!("{ix}: {value:+}").into());
739        content.set_value_color(|_, _, value| if value >= 0. { green() } else { red() });
740        let cx = cx.add_empty_window();
741        let tooltip = cx
742            .update(|window, cx| {
743                content.apply(
744                    Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
745                    &1.,
746                    || Some("Jan".into()),
747                    || Some([(blue(), "Open".into(), 2.), (blue(), "Close".into(), -1.)]),
748                    window,
749                    cx,
750                )
751            })
752            .expect("rows are given");
753
754        assert_eq!(tooltip.title_for_test().map(|t| t.as_ref()), Some("Jan"));
755        assert_eq!(
756            tooltip.rows_for_test(),
757            vec![
758                ("0: +2".into(), Some(green())),
759                ("1: -1".into(), Some(red()))
760            ]
761        );
762    }
763
764    /// Without a title of the chart's or the caller's, the tooltip has none, as a
765    /// radar with element labels shows.
766    #[gpui::test]
767    fn tooltip_fill_leaves_the_title_off_without_one(cx: &mut TestAppContext) {
768        let content = TooltipContent::<f64>::default();
769        let cx = cx.add_empty_window();
770        let tooltip = cx
771            .update(|window, cx| {
772                content.apply(
773                    Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
774                    &1.,
775                    || None,
776                    || Some([(blue(), "Alpha".into(), 80.)]),
777                    window,
778                    cx,
779                )
780            })
781            .expect("rows are given");
782
783        assert!(tooltip.title_for_test().is_none());
784        assert_eq!(tooltip.rows_for_test(), vec![("80".into(), None)]);
785    }
786
787    /// A caller's own content replaces the title and rows, which are never built,
788    /// so a series without a value doesn't hide it.
789    #[gpui::test]
790    fn tooltip_fill_renders_the_caller_content_without_building_rows(cx: &mut TestAppContext) {
791        let mut content = TooltipContent::<f64>::default();
792        content.set_title(|_| "Caller".into());
793        content.set_content(|_, _, _| div());
794        let built = Cell::new(false);
795        let cx = cx.add_empty_window();
796        let tooltip = cx.update(|window, cx| {
797            content.apply(
798                Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
799                &1.,
800                || {
801                    built.set(true);
802                    Some("Jan".into())
803                },
804                || -> Option<[(Hsla, SharedString, f64); 0]> {
805                    built.set(true);
806                    None
807                },
808                window,
809                cx,
810            )
811        });
812
813        let tooltip = tooltip.expect("the caller renders");
814        assert!(!built.get());
815        assert!(tooltip.title_for_test().is_none());
816        assert!(tooltip.rows_for_test().is_empty());
817    }
818}