Skip to main content

gpui_component/chart/
bar_chart.rs

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;
8
9use crate::{
10    ActiveTheme,
11    plot::{
12        AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAppear, PlotAxis, PlotLabel,
13        label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
14        scale::{PlotValue, Scale, ScaleBand, ScaleLinear},
15        shape::{Bar, BarAlignment},
16        tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
17    },
18};
19
20use super::{
21    AXIS_GAP, ChartAppear, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP,
22    build_band_labels, caller_id, format_tick, labeled_items, value_axis_gap,
23};
24
25/// How much the bars away from the hovered one fade, as a share of their opacity.
26const HOVER_DIM: f32 = 0.45;
27
28/// The hover a bar chart paints, sampled once per frame in [`Plot::hover`].
29#[derive(Clone, Copy)]
30struct BarHover {
31    /// Cross-axis center of the highlight band, gliding between bars.
32    center: f32,
33    /// How far the hover has faded in.
34    focus: f32,
35}
36
37#[derive(IntoPlot)]
38pub struct BarChart<T, B, V>
39where
40    T: 'static,
41    B: Eq + Hash + Into<SharedString> + 'static,
42    V: PlotValue,
43{
44    data: Vec<T>,
45    band: Option<Rc<dyn Fn(&T) -> B>>,
46    value: Option<Rc<dyn Fn(&T) -> V>>,
47    fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
48    #[allow(clippy::type_complexity)]
49    fill_gradient:
50        Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
51    tick_margin: usize,
52    label: Option<Rc<dyn Fn(&T) -> SharedString>>,
53    label_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
54    label_axis: bool,
55    value_axis: bool,
56    value_axis_label_placement: AxisLabelPlacement,
57    value_tick_count: usize,
58    value_tick_format: Option<TickFormat>,
59    band_count: Option<usize>,
60    band_tick_count: Option<usize>,
61    grid: bool,
62    grid_dashed: bool,
63    alignment: BarAlignment,
64    corner_radii: Corners<Pixels>,
65    padding_inner: f32,
66    padding_outer: f32,
67    max_band_width: Pixels,
68    min_length: f32,
69    id: ElementId,
70    interactive: bool,
71    appear: ChartAppear,
72    name: Option<SharedString>,
73    tooltip_content: TooltipContent<T>,
74    /// The label gaps of horizontal bars, measured in `prepaint` for the frame,
75    /// so `tooltip_state` (which has no window) can keep the hover off the labels.
76    horizontal_gaps: (f32, f32),
77    /// The value-axis gutter of vertical bars, measured in `prepaint`; see
78    /// [`value_axis_gap`].
79    value_label_gap: f32,
80    hover: Option<BarHover>,
81}
82
83impl<T, B, V> BarChart<T, B, V>
84where
85    B: Eq + Hash + Into<SharedString> + 'static,
86    V: PlotValue,
87{
88    #[track_caller]
89    pub fn new<I>(data: I) -> Self
90    where
91        I: IntoIterator<Item = T>,
92    {
93        Self {
94            data: data.into_iter().collect(),
95            band: None,
96            value: None,
97            fill: None,
98            fill_gradient: None,
99            tick_margin: 1,
100            label: None,
101            label_color: None,
102            label_axis: true,
103            value_axis: false,
104            value_axis_label_placement: AxisLabelPlacement::default(),
105            value_tick_count: 5,
106            value_tick_format: None,
107            band_count: None,
108            band_tick_count: None,
109            grid: true,
110            grid_dashed: true,
111            alignment: BarAlignment::default(),
112            corner_radii: Corners::all(px(0.)),
113            padding_inner: 0.4,
114            padding_outer: 0.2,
115            max_band_width: px(MAX_BAND_WIDTH),
116            min_length: 0.,
117            id: caller_id(),
118            interactive: true,
119            appear: ChartAppear::default(),
120            name: None,
121            tooltip_content: TooltipContent::default(),
122            horizontal_gaps: (0., 0.),
123            value_label_gap: VALUE_AXIS_GAP,
124            hover: None,
125        }
126    }
127
128    /// Name this chart's [`ElementId`], replacing the default taken from the
129    /// construction site.
130    ///
131    /// Pass one where a single construction site renders several of these
132    /// charts as siblings: they share the default id, and with it one hover
133    /// state and one path cache. The id must be unique among those siblings.
134    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
135        self.id = id.into();
136        self
137    }
138
139    /// Turn this chart's interactive layer on or off. On by default.
140    ///
141    /// The layer is the hitbox under the cursor and what it drives: a crosshair
142    /// marks the hovered band, and a tooltip shows its category and value. Turn
143    /// it off for a chart that only decorates, or one an element above it wants
144    /// the cursor for: without a hitbox it neither answers the mouse nor takes
145    /// the hover from what sits over it.
146    pub fn interactive(mut self, interactive: bool) -> Self {
147        self.interactive = interactive;
148        self
149    }
150
151    /// Draw the data in the first time this chart is painted. On by default.
152    ///
153    /// The theme sets how long it takes, and the system's reduced-motion
154    /// setting skips it. Turn it off for a chart that is painted again and
155    /// again as it scrolls in and out of view, such as one in each row of a
156    /// long list, where it would draw in every time.
157    pub fn appear(mut self, appear: bool) -> Self {
158        self.appear.set_enabled(appear);
159        self
160    }
161
162    /// Draw the data in again whenever `key` changes, such as the symbol or
163    /// period a chart shows.
164    ///
165    /// Without one the data draws in once, and later data paints in place.
166    pub fn appear_key(mut self, key: impl Hash) -> Self {
167        self.appear.set_key(key);
168        self
169    }
170
171    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
172    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
173        self.name = Some(name.into());
174        self
175    }
176
177    /// Set the hover tooltip's title for a datum, instead of its band value.
178    pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
179        self.tooltip_content.set_title(title);
180        self
181    }
182
183    /// Set the text of the tooltip row's value; the raw number by default.
184    ///
185    /// The closure receives the datum and the value the row reads.
186    pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
187        self.tooltip_content.set_value(move |d, _, v| value(d, v));
188        self
189    }
190
191    /// Color the tooltip row's value, such as green or red by its sign; the
192    /// tooltip's text color by default.
193    ///
194    /// The closure receives the same arguments as
195    /// [`tooltip_value`](Self::tooltip_value).
196    pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
197    where
198        H: Into<Hsla>,
199    {
200        self.tooltip_content
201            .set_value_color(move |d, _, value| color(d, value));
202        self
203    }
204
205    /// Draw the tooltip box's content for a datum yourself, in place of the
206    /// title and rows, for a layout they cannot express such as a table.
207    ///
208    /// The highlight band and where the box sits stay the chart's, and
209    /// [`tooltip_title`](Self::tooltip_title), [`tooltip_value`](Self::tooltip_value)
210    /// and [`tooltip_value_color`](Self::tooltip_value_color) no longer apply.
211    pub fn tooltip_content<E>(
212        mut self,
213        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
214    ) -> Self
215    where
216        E: IntoElement,
217    {
218        self.tooltip_content.set_content(content);
219        self
220    }
221
222    /// Map each datum to its band-axis value (the categorical/ordinal axis).
223    pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
224        self.band = Some(Rc::new(band));
225        self
226    }
227
228    /// Map each datum to its numeric value along the value axis.
229    pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
230        self.value = Some(Rc::new(value));
231        self
232    }
233
234    /// Set a per-datum verbatim fill.
235    ///
236    /// The closure receives:
237    ///
238    /// 1. the datum,
239    /// 2. the **bar's bounds** in pixel space, expressed relative to the
240    ///    chart's origin (i.e. the bar's painted rectangle within the chart),
241    /// 3. the **chart's bounds** in pixel space with origin `(0, 0)` and size
242    ///    equal to the full chart extent, and
243    /// 4. the bar's [`BarAlignment`] (so callers can branch on orientation,
244    ///    e.g. flip a gradient angle).
245    ///
246    /// Both rectangles share the same coordinate system, so callers can
247    /// implement arbitrary chart-aware backgrounds — bar-local gradients,
248    /// chart-wide gradients, patterns, sampled colormaps, etc. — without any
249    /// help from the library.
250    ///
251    /// Accepts any type convertible to [`Background`]. Setting this clears any
252    /// previously set [`BarChart::fill_gradient`].
253    pub fn fill<Bg>(
254        mut self,
255        fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
256    ) -> Self
257    where
258        Bg: Into<Background> + 'static,
259    {
260        self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
261            fill(t, bar_bounds, chart_bounds, alignment).into()
262        }));
263        self.fill_gradient = None;
264        self
265    }
266
267    /// Set a per-datum auto-oriented linear gradient fill.
268    ///
269    /// The closure receives the datum, the chart's full data range
270    /// (`chart_range`, derived from all data values), and a `chart_to_bar`
271    /// remap helper that maps a chart-value coordinate to a bar-local
272    /// gradient position (where `0.0` is the bar's base and `1.0` is its tip).
273    ///
274    /// Use bar-local positions directly for per-bar gradients (every bar
275    /// looks the same regardless of its value):
276    ///
277    /// ```ignore
278    /// .fill_gradient(|_, _, _| [
279    ///     linear_color_stop(c.opacity(0.3), 0.0),
280    ///     linear_color_stop(c, 1.0),
281    /// ])
282    /// ```
283    ///
284    /// Or use `chart_to_bar` to position stops at chart-relative values, so
285    /// each bar shows the slice of a chart-wide gradient corresponding to
286    /// its own `[base, value]` span:
287    ///
288    /// ```ignore
289    /// .fill_gradient(|_, chart_range, chart_to_bar| [
290    ///     linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())),
291    ///     linear_color_stop(c,              chart_to_bar(*chart_range.end())),
292    /// ])
293    /// ```
294    ///
295    /// Stop positions returned outside `[0, 1]` are clipped to the bar; the
296    /// library interpolates colors at the clip points so the on-bar gradient
297    /// still matches the chart-wide one.
298    ///
299    /// The gradient angle is derived from [`BarAlignment`] so stop-0 is at the
300    /// base and stop-1 at the tip. Setting this clears any previously set
301    /// [`BarChart::fill`].
302    pub fn fill_gradient(
303        mut self,
304        fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
305    ) -> Self {
306        self.fill_gradient = Some(Rc::new(fill));
307        self.fill = None;
308        self
309    }
310
311    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
312        self.tick_margin = tick_margin;
313        self
314    }
315
316    pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
317    where
318        S: Into<SharedString> + 'static,
319    {
320        self.label = Some(Rc::new(move |t| label(t).into()));
321        self
322    }
323
324    /// Color each bar's [`label`](Self::label) text, instead of the theme's
325    /// foreground for all of them.
326    ///
327    /// Takes a closure per bar, as [`fill`](Self::fill) does, so a label can
328    /// follow its bar's color.
329    pub fn label_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
330    where
331        H: Into<Hsla> + 'static,
332    {
333        self.label_color = Some(Rc::new(move |t| color(t).into()));
334        self
335    }
336
337    /// Show or hide the band-axis line and labels.
338    ///
339    /// Default is true.
340    pub fn label_axis(mut self, label_axis: bool) -> Self {
341        self.label_axis = label_axis;
342        self
343    }
344
345    /// Show or hide the value-axis tick labels.
346    ///
347    /// Placed [`Outside`](AxisLabelPlacement::Outside), the default, the labels
348    /// take a gutter along the band axis, left of vertical bars and below
349    /// horizontal ones.
350    ///
351    /// Default is false.
352    pub fn value_axis(mut self, value_axis: bool) -> Self {
353        self.value_axis = value_axis;
354        self
355    }
356
357    /// Set how many ticks the value axis carries, evenly spaced from the
358    /// baseline to the far edge with both ends included, which drives both the
359    /// grid lines and the value-axis tick labels.
360    ///
361    /// Unlike [`Self::tick_margin`], a stride over the band axis categories,
362    /// this counts the ticks themselves. Values below 2 are raised to 2.
363    ///
364    /// Default is 5.
365    pub fn value_tick_count(mut self, count: usize) -> Self {
366        self.value_tick_count = count.max(2);
367        self
368    }
369
370    /// Set where the value-axis tick labels sit: in a gutter beside the bars,
371    /// or inside the plot beside their grid lines, which keeps the bars' room.
372    ///
373    /// Default is [`AxisLabelPlacement::Outside`].
374    pub fn value_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
375        self.value_axis_label_placement = placement;
376        self
377    }
378
379    /// Set the text of each value-axis tick label from the value at its tick.
380    ///
381    /// Default is whole numbers bare and the rest to one decimal.
382    pub fn value_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
383    where
384        S: Into<SharedString> + 'static,
385    {
386        self.value_tick_format = Some(Rc::new(move |value| format(value).into()));
387        self
388    }
389
390    /// Lay the band axis out for `count` bands instead of the data's own
391    /// length.
392    ///
393    /// The data takes the leading bands in order and the rest stay empty, so
394    /// each bar keeps its width and place as the data grows. A `count` below
395    /// the data's length has no effect.
396    pub fn band_count(mut self, count: usize) -> Self {
397        self.band_count = Some(count);
398        self
399    }
400
401    /// Label `count` of the bands, spread evenly from the first to the last,
402    /// instead of every `tick_margin`-th.
403    ///
404    /// With [`Self::band_count`] set, the labels spread over all the bands, so
405    /// they keep their places as the data grows; one that falls on an empty band
406    /// is not drawn yet.
407    pub fn band_tick_count(mut self, count: usize) -> Self {
408        self.band_tick_count = Some(count);
409        self
410    }
411
412    pub fn grid(mut self, grid: bool) -> Self {
413        self.grid = grid;
414        self
415    }
416
417    /// Draw the grid dashed or solid.
418    ///
419    /// Default is true.
420    pub fn grid_dashed(mut self, dashed: bool) -> Self {
421        self.grid_dashed = dashed;
422        self
423    }
424
425    /// Set the bar alignment.
426    ///
427    /// Default is [`BarAlignment::Bottom`].
428    pub fn alignment(mut self, alignment: BarAlignment) -> Self {
429        self.alignment = alignment;
430        self
431    }
432
433    /// Set the corner radii applied to every bar rectangle.
434    ///
435    /// Use [`Corners::all`] for uniform rounding, or construct [`Corners`] manually
436    /// to round only specific corners (e.g. just the tip end of each bar).
437    pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
438        self.corner_radii = corner_radii.into();
439        self
440    }
441
442    /// Set the gap between neighbouring bars, as a share of each band.
443    ///
444    /// Default is 0.4.
445    pub fn padding_inner(mut self, padding: f32) -> Self {
446        self.padding_inner = padding;
447        self
448    }
449
450    /// Set the gap before the first bar and after the last, as a share of a band.
451    ///
452    /// Default is 0.2.
453    pub fn padding_outer(mut self, padding: f32) -> Self {
454        self.padding_outer = padding;
455        self
456    }
457
458    /// Keep every bar at most `width` wide, so a few bars across a wide chart
459    /// stay narrow instead of filling their bands.
460    ///
461    /// Default is 30px.
462    pub fn max_band_width(mut self, width: impl Into<Pixels>) -> Self {
463        self.max_band_width = width.into();
464        self
465    }
466
467    /// Draw every bar at least `length` pixels long, so a zero or tiny value
468    /// still shows a stub instead of disappearing into the baseline.
469    ///
470    /// The stub grows the way the bar's value would: away from the zero line,
471    /// to the negative side for a negative value and to the positive side for
472    /// zero. A bar already that long is left alone.
473    ///
474    /// Default is 0.
475    pub fn min_length(mut self, length: f32) -> Self {
476        self.min_length = length;
477        self
478    }
479
480    /// The band scale (matching `paint`): spans the height for horizontal bars, the width
481    /// otherwise. Shared by `tooltip_state` and `tooltip`.
482    fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
483        let band_fn = self.band.as_ref()?;
484        let band_extent = if self.alignment.is_horizontal() {
485            bounds.size.height.as_f32()
486        } else {
487            bounds.size.width.as_f32()
488        };
489        // Value-axis labels eat into the band extent at one end; `band_offset`
490        // shifts the bands away from that end when it is the leading one.
491        let extent = (band_extent - self.value_axis_gap()).max(0.);
492        Some(
493            ScaleBand::new(self.data.iter().map(|v| band_fn(v)), [0., extent])
494                .band_count(self.band_count.unwrap_or(0))
495                .max_band_width(self.max_band_width.as_f32())
496                .padding_inner(self.padding_inner)
497                .padding_outer(self.padding_outer),
498        )
499    }
500
501    /// Offset added to every band-scale tick.
502    ///
503    /// [`ScaleBand`] ignores the start of its range, so vertical bars are shifted
504    /// by hand to clear the value-axis labels on their left. Horizontal bars put
505    /// those labels below the plot, past the end of the band axis, so they need no
506    /// shift.
507    fn band_offset(&self) -> f32 {
508        if self.alignment.is_horizontal() {
509            0.
510        } else {
511            self.value_axis_gap()
512        }
513    }
514
515    /// The value axis for `bounds`: the scale the bars grow along, and the
516    /// pixel positions of its baseline and far edge. `paint` lays the bars out
517    /// on it and the tooltip reads the hovered bar's frame from it.
518    fn value_scale(&self, bounds: Bounds<Pixels>) -> Option<(ScaleLinear<V>, f32, f32)> {
519        let value_fn = self.value.as_ref()?;
520        let value_dim = if self.alignment.is_horizontal() {
521            bounds.size.width.as_f32()
522        } else {
523            bounds.size.height.as_f32()
524        };
525        let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
526        // For horizontal charts the band labels (category names) are rendered
527        // along the value axis and can be arbitrarily wide, so we measure the
528        // actual maximum label width instead of using a fixed constant.
529        // Similarly, value labels (numbers) at the bar ends are measured so the
530        // scale range is always shrunk by exactly the right amount.
531        // Vertical bars keep a line of text clear past the tallest bar when they
532        // carry value labels, so the label above it stays inside the chart.
533        let far_gap = if self.label.is_some() {
534            TEXT_HEIGHT
535        } else {
536            10.
537        };
538        let (band_gap, value_end_gap) = if self.alignment.is_horizontal() {
539            self.horizontal_gaps
540        } else {
541            (axis_gap, far_gap)
542        };
543        // The baseline, and the far edge opposite it.
544        let (baseline, far) = match self.alignment {
545            BarAlignment::Bottom => (value_dim - axis_gap, far_gap),
546            BarAlignment::Top => (axis_gap, value_dim - far_gap),
547            BarAlignment::Left => (band_gap, value_dim - value_end_gap),
548            BarAlignment::Right => (value_dim - band_gap, value_end_gap),
549        };
550        let scale = ScaleLinear::new(
551            self.data.iter().map(|v| value_fn(v)).chain(Some(V::zero())),
552            [baseline, far],
553        );
554        Some((scale, baseline, far))
555    }
556
557    /// The frame `paint` gives datum `d`'s bar, the one `fill` receives.
558    fn bar_frame(
559        &self,
560        d: &T,
561        band_scale: &ScaleBand<B>,
562        bounds: Bounds<Pixels>,
563    ) -> Option<Bounds<f32>> {
564        let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
565        let (value_scale, baseline, _) = self.value_scale(bounds)?;
566        let zero = value_scale.tick(&V::zero()).unwrap_or(baseline);
567        let cross = band_scale.tick(&band_fn(d))? + self.band_offset();
568        let end = bar_end(
569            &value_scale,
570            value_fn(d),
571            zero,
572            self.alignment,
573            self.min_length,
574        )?;
575        let (lo, length) = (end.min(zero), (end - zero).abs());
576        let band_width = band_scale.band_width();
577        Some(if self.alignment.is_horizontal() {
578            Bounds {
579                origin: Point::new(lo, cross),
580                size: Size::new(length, band_width),
581            }
582        } else {
583            Bounds {
584                origin: Point::new(cross, lo),
585                size: Size::new(band_width, length),
586            }
587        })
588    }
589
590    /// The data range `fill_gradient` reads, the same for every bar.
591    fn gradient_range(&self) -> RangeInclusive<f32> {
592        let Some(value_fn) = self.value.as_ref() else {
593            return 0.0..=0.0;
594        };
595        let mut lo = 0.0_f32;
596        let mut hi = 0.0_f32;
597        for v in &self.data {
598            if let Some(f) = value_fn(v).to_f32() {
599                lo = lo.min(f);
600                hi = hi.max(f);
601            }
602        }
603        lo..=hi
604    }
605
606    /// The color a tooltip row shows for datum `d`: its bar's, the first stop
607    /// of a gradient, or the default fill when `fill` returns a gradient,
608    /// whose stops can't be read back. `frame` is the bar's, as `paint` lays it
609    /// out.
610    fn bar_color(&self, d: &T, frame: Bounds<f32>, bounds: Bounds<Pixels>, cx: &App) -> Hsla {
611        let default = cx.theme().chart_2;
612        if let Some(fill) = self.fill_gradient.as_ref() {
613            let value = self
614                .value
615                .as_ref()
616                .and_then(|value_fn| value_fn(d).to_f32())
617                .unwrap_or(0.);
618            let [first, _] = bar_gradient(fill.as_ref(), d, value, self.gradient_range());
619            return first.color;
620        }
621        let Some(fill) = self.fill.as_ref() else {
622            return default;
623        };
624        let chart_bounds = Bounds {
625            origin: Point::new(0., 0.),
626            size: Size::new(bounds.size.width.as_f32(), bounds.size.height.as_f32()),
627        };
628        fill(d, frame, chart_bounds, self.alignment)
629            .as_solid()
630            .unwrap_or(default)
631    }
632
633    /// The gutter the value-axis labels take along the band axis: none unless
634    /// they are shown outside the plot.
635    fn value_axis_gap(&self) -> f32 {
636        if !self.value_axis || self.value_axis_label_placement != AxisLabelPlacement::Outside {
637            0.
638        } else if self.alignment.is_horizontal() {
639            // Below the plot, where the gap is a line of text tall.
640            VALUE_AXIS_GAP
641        } else {
642            self.value_label_gap
643        }
644    }
645
646    /// The bands the band axis is laid out for: the data's, or the
647    /// [`Self::band_count`] when larger.
648    fn band_slots(&self) -> usize {
649        self.band_count.unwrap_or(0).max(self.data.len())
650    }
651
652    /// The value-axis tick label text, from the domain maximum at the far end
653    /// down to the minimum at the baseline, matching the value scale.
654    fn value_tick_labels(&self) -> Vec<SharedString> {
655        let Some(value_fn) = self.value.as_ref() else {
656            return vec![];
657        };
658        // The data plus zero, as `value_scale` spans.
659        let (lo, hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
660            let f = value_fn(v).to_f32().unwrap_or(0.);
661            (lo.min(f), hi.max(f))
662        });
663        let steps = (self.value_tick_count - 1) as f32;
664        (0..self.value_tick_count)
665            .map(|i| {
666                let value = (hi - (hi - lo) * i as f32 / steps) as f64;
667                match self.value_tick_format.as_ref() {
668                    Some(format) => format(value),
669                    None => format_tick(value),
670                }
671            })
672            .collect()
673    }
674
675    /// Label gaps `(band_side, value_end_side)` reserved along the value axis for
676    /// horizontal bars, measured from the actual label text. Measured once per frame
677    /// in `prepaint` and kept in `horizontal_gaps`, so `paint` and the tooltip share
678    /// one measurement and the crosshair lines up with the bar region.
679    fn measure_horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
680        let Some(band_fn) = self.band.as_ref() else {
681            return (0., 0.);
682        };
683        let font_size = px(TEXT_SIZE);
684        let band_gap = if self.label_axis {
685            self.data
686                .iter()
687                .map(|v| {
688                    let s: SharedString = band_fn(v).into();
689                    measure_text_width(&s, font_size, window)
690                })
691                .fold(0f32, f32::max)
692                + TEXT_GAP * 2.
693        } else {
694            0.
695        };
696        let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
697            self.data
698                .iter()
699                .map(|v| measure_text_width(&label_fn(v), font_size, window))
700                .fold(0f32, f32::max)
701                + TEXT_GAP * 2.
702        } else {
703            TEXT_GAP * 4.
704        };
705        (band_gap, value_end_gap)
706    }
707
708    /// The extent `(start, length)` of the bars along the value axis, which the
709    /// hover is confined to so the axis labels never show a tooltip.
710    fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
711        if self.alignment.is_horizontal() {
712            let (band_gap, value_end_gap) = self.horizontal_gaps;
713            let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
714            let start = if matches!(self.alignment, BarAlignment::Left) {
715                band_gap
716            } else {
717                value_end_gap
718            };
719            (start, length)
720        } else {
721            let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
722            let length = bounds.size.height.as_f32() - axis_gap;
723            let start = if matches!(self.alignment, BarAlignment::Top) {
724                axis_gap
725            } else {
726                0.
727            };
728            (start, length)
729        }
730    }
731
732    /// Whether the cursor is over a bar's row or column rather than the axis labels.
733    fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
734        let (start, length) = self.value_extent(bounds);
735        if self.alignment.is_horizontal() {
736            let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
737            (start..=start + length).contains(&position.x.as_f32())
738                && !(self.value_axis_gap() > 0. && position.y.as_f32() > value_labels_top)
739        } else {
740            (start..=start + length).contains(&position.y.as_f32())
741                && position.x.as_f32() >= self.band_offset()
742        }
743    }
744}
745
746impl<T, B, V> Plot for BarChart<T, B, V>
747where
748    B: Eq + Hash + Into<SharedString> + 'static,
749    V: PlotValue,
750{
751    fn prepaint(
752        &mut self,
753        _bounds: Bounds<Pixels>,
754        window: &mut Window,
755        _cx: &mut App,
756    ) -> Vec<AnyElement> {
757        self.horizontal_gaps = if self.alignment.is_horizontal() {
758            self.measure_horizontal_gaps(window)
759        } else {
760            (0., 0.)
761        };
762        if self.value_axis && !self.alignment.is_horizontal() {
763            self.value_label_gap = value_axis_gap(self.value_tick_labels(), window);
764        }
765        vec![]
766    }
767
768    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
769        let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
770            return;
771        };
772
773        let total_width = bounds.size.width.as_f32();
774        let total_height = bounds.size.height.as_f32();
775        let alignment = self.alignment;
776        let is_horizontal = alignment.is_horizontal();
777
778        // Band scale spans the full extent perpendicular to the value axis. Shared with the
779        // tooltip via `band_scale()` so the bars and the hover crosshair stay aligned.
780        let Some(band_scale) = self.band_scale(bounds) else {
781            return;
782        };
783        let band_width = band_scale.band_width();
784
785        let Some((value_scale, baseline, far)) = self.value_scale(bounds) else {
786            return;
787        };
788
789        // Where zero sits along the value axis. Bars grow from here rather than from
790        // the geometric baseline, so negative values extend to the opposite side. With
791        // no negative data zero is the domain minimum and this is the baseline.
792        let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
793        let band_offset = self.band_offset();
794
795        // Grid lines and the zero line span their bounds edge to edge, so they are
796        // painted into bounds inset by the value-axis gap. Without this they run
797        // straight through the value-axis labels.
798        let value_axis_gap = self.value_axis_gap();
799        let plot_bounds = if is_horizontal {
800            Bounds {
801                origin: bounds.origin,
802                size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
803            }
804        } else {
805            Bounds {
806                origin: bounds.origin + point(px(value_axis_gap), px(0.)),
807                size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
808            }
809        };
810
811        // Draw band axis (with categorical labels).
812        let mut axis = PlotAxis::new().stroke(cx.theme().border);
813        if self.label_axis {
814            match alignment {
815                BarAlignment::Bottom | BarAlignment::Top => {
816                    axis = axis.x(zero_pixel);
817
818                    // Labels are placed one at a time rather than through
819                    // `x_label`, because a chart with negative values needs them
820                    // on either side of the zero line: each label goes on the side
821                    // its own bar leaves empty.
822                    let labeled =
823                        labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin);
824                    let labels = self
825                        .data
826                        .iter()
827                        .enumerate()
828                        .filter(|(i, _)| labeled[*i])
829                        .filter_map(|(_, d)| {
830                            let band_x = band_scale.tick(&band_fn(d))?;
831                            let value = value_fn(d).to_f32().unwrap_or(0.);
832                            let label_y = if label_below_zero_line(value, alignment) {
833                                zero_pixel + TEXT_GAP
834                            } else {
835                                zero_pixel - TEXT_GAP - TEXT_SIZE
836                            };
837
838                            Some(
839                                Text::new(
840                                    band_fn(d).into(),
841                                    point(px(band_x + band_offset + band_width / 2.), px(label_y)),
842                                    cx.theme().muted_foreground,
843                                )
844                                .align(TextAlign::Center),
845                            )
846                        })
847                        .collect();
848                    PlotLabel::new(labels).paint(&bounds, window, cx);
849                }
850                BarAlignment::Left | BarAlignment::Right => {
851                    let labels = build_band_labels(
852                        &self.data,
853                        band_fn.as_ref(),
854                        &band_scale,
855                        band_width,
856                        &labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin),
857                        cx.theme().muted_foreground,
858                    );
859                    let (side, align) = if matches!(alignment, BarAlignment::Left) {
860                        (AxisLabelSide::Start, TextAlign::Right)
861                    } else {
862                        (AxisLabelSide::End, TextAlign::Left)
863                    };
864                    axis = axis
865                        .y(zero_pixel)
866                        .y_label_side(side)
867                        .y_label(labels.into_iter().map(|t| t.align(align)));
868                }
869            }
870        }
871        axis.paint(&plot_bounds, window, cx);
872
873        let value_ticks = value_tick_positions(far, baseline, self.value_tick_count);
874        let steps = value_ticks.len() - 1;
875
876        // Draw grid, excluding the line at the baseline.
877        if self.grid {
878            let grid = Grid::new().stroke(cx.theme().chart_grid);
879            let grid = if self.grid_dashed {
880                grid.dash_array(&[px(4.), px(2.)])
881            } else {
882                grid
883            };
884            let lines = value_ticks[..steps].to_vec();
885            let grid = if is_horizontal {
886                grid.x(lines)
887            } else {
888                grid.y(lines)
889            };
890            grid.paint(&plot_bounds, window);
891        }
892
893        // Labels inside the plot are painted after the bars, so no bar covers them.
894        let mut inside_labels = None;
895        if self.value_axis {
896            // Ticks run from `far` (the domain maximum) to `baseline` (the minimum),
897            // so the labels walk the domain in the same direction.
898            let color = cx.theme().muted_foreground;
899            let texts = self
900                .value_tick_labels()
901                .into_iter()
902                .zip(value_ticks.iter().copied());
903
904            match self.value_axis_label_placement {
905                // The labels go in the gap `band_scale` kept clear for them,
906                // right-aligned against the plot area for vertical bars and centred
907                // under it otherwise.
908                AxisLabelPlacement::Outside => {
909                    let labels = texts.map(|(text, tick)| AxisText::new(text, px(tick), color));
910                    let value_axis = if is_horizontal {
911                        PlotAxis::new()
912                            .x_axis(false)
913                            .x(px(total_height - VALUE_AXIS_GAP))
914                            .x_label(labels.map(|t| t.align(TextAlign::Center)))
915                    } else {
916                        PlotAxis::new()
917                            .y_axis(false)
918                            .y(px(value_axis_gap - TEXT_GAP * 2.))
919                            .y_label(labels.map(|t| t.align(TextAlign::Right)))
920                    };
921                    value_axis.paint(&bounds, window, cx);
922                }
923                // Over the plot beside each grid line: above it for vertical bars
924                // but for the topmost, which would leave the plot, and along the
925                // bottom edge for horizontal ones.
926                AxisLabelPlacement::Inside => {
927                    let labels = texts
928                        .map(|(text, tick)| {
929                            if is_horizontal {
930                                Text::new(text, point(tick, total_height - TEXT_HEIGHT), color)
931                                    .align(TextAlign::Center)
932                            } else {
933                                let top = if tick < TEXT_HEIGHT {
934                                    tick + TEXT_GAP
935                                } else {
936                                    tick - TEXT_HEIGHT
937                                };
938                                Text::new(text, point(TEXT_GAP, top), color)
939                            }
940                        })
941                        .collect();
942                    inside_labels = Some(PlotLabel::new(labels));
943                }
944            }
945        }
946
947        // Draw bars.
948        let band_fn_cloned = band_fn.clone();
949        let value_fn_cloned = value_fn.clone();
950        let default_fill: Background = cx.theme().chart_2.into();
951        let fill = self.fill.clone();
952        let fill_gradient = self.fill_gradient.clone();
953        let label_color = cx.theme().foreground;
954        let label_color_fn = self.label_color.clone();
955        let min_length = self.min_length;
956
957        // Chart bounds in pixel space, with origin (0, 0) and size equal to
958        // the full chart extent. Passed to user `fill` closures so they can
959        // position chart-wide backgrounds (gradients, patterns, etc.).
960        let chart_bounds: Bounds<f32> = Bounds {
961            origin: Point::new(0., 0.),
962            size: Size::new(total_width, total_height),
963        };
964
965        // Chart data range in f32 — passed to `fill_gradient` callers and used
966        // by the `chart_to_bar` remap helper.
967        let chart_range = self.gradient_range();
968
969        // The hovered bar keeps its color while the others fade behind it. The
970        // highlight band springs between bars, so each bar's emphasis follows the
971        // band's distance from it and the focus hands over as the band slides.
972        let hover = self.hover;
973        let step = band_scale.step().max(f32::EPSILON);
974        let emphasis = move |frame: Bounds<f32>| -> f32 {
975            let Some(hover) = hover else {
976                return 1.;
977            };
978            let center = if is_horizontal {
979                frame.origin.y + frame.size.height / 2.
980            } else {
981                frame.origin.x + frame.size.width / 2.
982            };
983            let distance = ((center - hover.center).abs() / step).min(1.);
984            1. - HOVER_DIM * hover.focus * distance
985        };
986
987        // Every bar grows out of the zero line together as the chart appears,
988        // the way Chart.js draws bars in.
989        let appear = self.appear.get().progress();
990
991        let mut bar = Bar::new()
992            .data(&self.data)
993            .alignment(alignment)
994            .band_width(band_width)
995            .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
996            .base(move |_| zero_pixel)
997            .value(move |d| {
998                let end = bar_end(
999                    &value_scale,
1000                    value_fn_cloned(d),
1001                    zero_pixel,
1002                    alignment,
1003                    min_length,
1004                )?;
1005                Some(zero_pixel + (end - zero_pixel) * appear)
1006            })
1007            .corner_radii(self.corner_radii);
1008
1009        bar = match (fill, fill_gradient) {
1010            (_, Some(fg)) => {
1011                let value_fn_for_grad = value_fn.clone();
1012                bar.fill(move |d, frame, alignment| {
1013                    let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
1014                    let [s0, s1] = bar_gradient(fg.as_ref(), d, v, chart_range.clone());
1015                    let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
1016                    bg.opacity(emphasis(frame))
1017                })
1018            }
1019            (Some(f), _) => bar.fill(move |d, frame, alignment| {
1020                f(d, frame, chart_bounds, alignment).opacity(emphasis(frame))
1021            }),
1022            _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))),
1023        };
1024
1025        if let Some(label) = self.label.as_ref() {
1026            let label = label.clone();
1027            let text_align = match alignment {
1028                BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
1029                BarAlignment::Left => TextAlign::Left,
1030                BarAlignment::Right => TextAlign::Right,
1031            };
1032            bar = bar.label(move |d, p| {
1033                // A value label rides the end of its bar and fades in with it.
1034                let color = label_color_fn
1035                    .as_ref()
1036                    .map_or(label_color, |f| f(d))
1037                    .opacity(appear);
1038                vec![Text::new(label(d), p, color).align(text_align)]
1039            });
1040        }
1041
1042        bar.paint(&bounds, window, cx);
1043        if let Some(labels) = inside_labels {
1044            labels.paint(&bounds, window, cx);
1045        }
1046    }
1047
1048    fn id(&self) -> Option<ElementId> {
1049        Some(self.id.clone())
1050    }
1051
1052    fn interactive(&self) -> bool {
1053        self.interactive
1054    }
1055
1056    fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) {
1057        self.appear.update(appear);
1058    }
1059
1060    fn appear_generation(&self) -> Option<u64> {
1061        self.appear.generation()
1062    }
1063
1064    fn tooltip_state(
1065        &self,
1066        position: Point<Pixels>,
1067        bounds: Bounds<Pixels>,
1068        _cx: &App,
1069    ) -> Option<TooltipState> {
1070        let band_fn = self.band.as_ref()?;
1071        self.value.as_ref()?;
1072
1073        // Skip the tooltip when the cursor is over the axis labels, not a bar.
1074        if !self.is_over_bars(position, bounds) {
1075            return None;
1076        }
1077
1078        // Only the band scale is needed to hit-test which bar is hovered; the label
1079        // gaps were measured in `prepaint`, so no `window` is required here.
1080        let is_horizontal = self.alignment.is_horizontal();
1081        let band_scale = self.band_scale(bounds)?;
1082        let band_width = band_scale.band_width();
1083
1084        let band_offset = self.band_offset();
1085        let cursor_band = if is_horizontal {
1086            position.y
1087        } else {
1088            position.x
1089        };
1090        let index = band_scale.nearest_index(cursor_band.as_f32() - band_offset);
1091        let d = self.data.get(index)?;
1092        let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
1093
1094        // Vertical bars: vertical crosshair at the bar's x. Horizontal bars: horizontal
1095        // crosshair at the bar's y. The box tracks the cursor either way.
1096        let cross_line = if is_horizontal {
1097            point(position.x, px(center))
1098        } else {
1099            point(px(center), position.y)
1100        };
1101
1102        Some(TooltipState::new(index, cross_line, vec![]))
1103    }
1104
1105    fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
1106        self.hover = hover.map(|hover| {
1107            // The band slides to the hovered bar; on the first hovered frame it
1108            // adopts the bar instead of travelling from where the last hover ended.
1109            let target = if self.alignment.is_horizontal() {
1110                hover.state().cross_line.y
1111            } else {
1112                hover.state().cross_line.x
1113            };
1114            let center = hover.glide(("bar-chart", "band"), target, window, cx);
1115            BarHover {
1116                center: center.as_f32(),
1117                focus: hover.progress(),
1118            }
1119        });
1120    }
1121
1122    fn tooltip(
1123        &self,
1124        state: &TooltipState,
1125        cursor: Point<Pixels>,
1126        bounds: Bounds<Pixels>,
1127        window: &mut Window,
1128        cx: &mut App,
1129    ) -> Option<AnyElement> {
1130        let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
1131        let d = self.data.get(state.index)?;
1132        let name = self.name.clone().unwrap_or_default();
1133
1134        // Highlight the hovered bar with a translucent band the width of the bar, instead
1135        // of a hairline. Confined to the plot area so it doesn't cover the axis labels,
1136        // and centered where the band has glided to, which the other bars also fade by.
1137        let band_scale = self.band_scale(bounds)?;
1138        let band_width = band_scale.band_width();
1139        let center = self.hover.map_or(state.cross_line, |hover| {
1140            if self.alignment.is_horizontal() {
1141                point(state.cross_line.x, px(hover.center))
1142            } else {
1143                point(px(hover.center), state.cross_line.y)
1144            }
1145        });
1146        let (start, length) = self.value_extent(bounds);
1147        let cross_line = if self.alignment.is_horizontal() {
1148            CrossLine::new(center)
1149                .horizontal()
1150                .h_span(start, length)
1151                .band(px(band_width))
1152        } else {
1153            CrossLine::new(center)
1154                .span(start, length)
1155                .band(px(band_width))
1156        };
1157
1158        let frame = self.bar_frame(d, &band_scale, bounds).unwrap_or_default();
1159        let swatch = self.bar_color(d, frame, bounds, cx);
1160
1161        // Follow the cursor; `hover` already glides the band.
1162        let tooltip = Tooltip::new(cursor, bounds.size)
1163            .glide(false)
1164            .gap(px(8.))
1165            .cross_line(cross_line);
1166
1167        let tooltip = self.tooltip_content.apply(
1168            tooltip,
1169            d,
1170            || Some(band_fn(d).into()),
1171            || Some([(swatch, name, value_fn(d).to_f64()?)]),
1172            window,
1173            cx,
1174        )?;
1175
1176        Some(tooltip.into_any_element())
1177    }
1178}
1179
1180/// The end a bar showing `value` reaches along the value axis, at least
1181/// `min_length` pixels from `zero`.
1182fn bar_end<V>(
1183    scale: &ScaleLinear<V>,
1184    value: V,
1185    zero: f32,
1186    alignment: BarAlignment,
1187    min_length: f32,
1188) -> Option<f32>
1189where
1190    V: PlotValue,
1191{
1192    let tick = scale.tick(&value)?;
1193    Some(extend_to_min_length(
1194        tick,
1195        zero,
1196        value < V::zero(),
1197        alignment,
1198        min_length,
1199    ))
1200}
1201
1202/// Push a bar's value end away from `zero` until the bar is `min` pixels long,
1203/// in the direction its value grows for `alignment`.
1204fn extend_to_min_length(
1205    tick: f32,
1206    zero: f32,
1207    negative: bool,
1208    alignment: BarAlignment,
1209    min: f32,
1210) -> f32 {
1211    if (tick - zero).abs() >= min {
1212        return tick;
1213    }
1214    let grows_toward_origin = matches!(alignment, BarAlignment::Bottom | BarAlignment::Right);
1215    if grows_toward_origin != negative {
1216        zero - min
1217    } else {
1218        zero + min
1219    }
1220}
1221
1222/// The two stops `fill` gives datum `d` with bar value `value`, mapped from the
1223/// chart's `range` onto the bar and clipped to it.
1224fn bar_gradient<T>(
1225    fill: &dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2],
1226    d: &T,
1227    value: f32,
1228    range: RangeInclusive<f32>,
1229) -> [LinearColorStop; 2] {
1230    let bar_lo = value.min(0.);
1231    let bar_span = (value.max(0.) - bar_lo).max(f32::EPSILON);
1232    let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
1233    clip_stops_to_bar(fill(d, range, &chart_to_bar))
1234}
1235
1236/// Clip a two-stop gradient to bar-local `[0, 1]`, interpolating colors at the
1237/// clip points so the on-bar gradient matches the (possibly broader) gradient
1238/// the caller defined.
1239///
1240/// When a stop position falls outside `[0, 1]` (e.g. because `chart_to_bar`
1241/// returned a value past the bar's edge for a chart-relative gradient),
1242/// gpui's renderer would clamp the position and lose the gradient effect.
1243/// This function instead replaces such a stop with the color sampled along
1244/// the line through both stops at position `0.0` or `1.0`, preserving the
1245/// visual slice.
1246fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
1247    let [a, b] = stops;
1248    let p0 = a.percentage;
1249    let p1 = b.percentage;
1250    let lerp = |t: f32| -> Hsla {
1251        Hsla {
1252            h: a.color.h + (b.color.h - a.color.h) * t,
1253            s: a.color.s + (b.color.s - a.color.s) * t,
1254            l: a.color.l + (b.color.l - a.color.l) * t,
1255            a: a.color.a + (b.color.a - a.color.a) * t,
1256        }
1257    };
1258    let span = p1 - p0;
1259    let sample = |target: f32| -> Hsla {
1260        if span.abs() < f32::EPSILON {
1261            a.color
1262        } else {
1263            lerp((target - p0) / span)
1264        }
1265    };
1266    let new_a = if (0. ..=1.).contains(&p0) {
1267        a
1268    } else {
1269        LinearColorStop {
1270            color: sample(p0.clamp(0., 1.)),
1271            percentage: p0.clamp(0., 1.),
1272        }
1273    };
1274    let new_b = if (0. ..=1.).contains(&p1) {
1275        b
1276    } else {
1277        LinearColorStop {
1278            color: sample(p1.clamp(0., 1.)),
1279            percentage: p1.clamp(0., 1.),
1280        }
1281    };
1282    [new_a, new_b]
1283}
1284
1285/// Whether a vertical bar's category label belongs below the zero line.
1286///
1287/// A bar grows away from the zero line, so its label goes on the side the bar
1288/// leaves empty. Which side that is flips with both the sign of the value and the
1289/// alignment. A zero-length bar counts as positive, which puts its label in the
1290/// axis gap rather than inside the plot.
1291fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
1292    (value < 0.) == (alignment == BarAlignment::Top)
1293}
1294
1295/// `count` evenly spaced tick positions along the value axis.
1296///
1297/// Runs from `far` (the value domain's maximum) through `baseline` (its minimum)
1298/// inclusive, so the last position is the baseline. `count` is at least 2.
1299fn value_tick_positions(far: f32, baseline: f32, count: usize) -> Vec<f32> {
1300    let steps = (count - 1) as f32;
1301    (0..count)
1302        .map(|i| far + (baseline - far) * i as f32 / steps)
1303        .collect()
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309
1310    #[test]
1311    fn test_label_below_zero_line() {
1312        // Bottom-aligned: positive bars grow up, leaving the space below free.
1313        assert!(label_below_zero_line(5., BarAlignment::Bottom));
1314        assert!(label_below_zero_line(0., BarAlignment::Bottom));
1315        assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
1316
1317        // Top-aligned bars grow the other way, so the sides swap.
1318        assert!(!label_below_zero_line(5., BarAlignment::Top));
1319        assert!(!label_below_zero_line(0., BarAlignment::Top));
1320        assert!(label_below_zero_line(-5., BarAlignment::Top));
1321    }
1322
1323    #[test]
1324    fn test_value_tick_positions() {
1325        // Both ends are included, so 5 ticks means 4 intervals.
1326        assert_eq!(
1327            value_tick_positions(10., 110., 5),
1328            vec![10., 35., 60., 85., 110.]
1329        );
1330
1331        // Top-aligned charts have the baseline before the far edge.
1332        assert_eq!(value_tick_positions(110., 10., 3), vec![110., 60., 10.]);
1333
1334        assert_eq!(value_tick_positions(0., 50., 2), vec![0., 50.]);
1335    }
1336
1337    #[test]
1338    fn test_min_length_extends_away_from_zero() {
1339        // A zero or tiny bar grows the way a positive one would.
1340        assert_eq!(
1341            extend_to_min_length(100., 100., false, BarAlignment::Bottom, 2.),
1342            98.
1343        );
1344        assert_eq!(
1345            extend_to_min_length(10., 10., false, BarAlignment::Top, 2.),
1346            12.
1347        );
1348        assert_eq!(
1349            extend_to_min_length(10., 10., false, BarAlignment::Left, 2.),
1350            12.
1351        );
1352        assert_eq!(
1353            extend_to_min_length(90., 90., false, BarAlignment::Right, 2.),
1354            88.
1355        );
1356
1357        // A small negative bar grows to the other side of the zero line.
1358        assert_eq!(
1359            extend_to_min_length(50.5, 50., true, BarAlignment::Bottom, 2.),
1360            52.
1361        );
1362
1363        // A bar already long enough is left alone.
1364        assert_eq!(
1365            extend_to_min_length(40., 100., false, BarAlignment::Bottom, 2.),
1366            40.
1367        );
1368    }
1369
1370    #[test]
1371    fn value_tick_labels_walk_the_domain_from_the_far_end() {
1372        use super::BarChart;
1373
1374        let chart = BarChart::new([10., 20.])
1375            .band(|v| format!("{v}"))
1376            .value(|v| *v)
1377            .value_tick_count(3);
1378        assert_eq!(chart.value_tick_labels(), vec!["20", "10", "0"]);
1379
1380        let money = chart.value_tick_format(|v| format!("${v:.0}"));
1381        assert_eq!(money.value_tick_labels(), vec!["$20", "$10", "$0"]);
1382
1383        // Labels spread over every band, so they stay put as the data grows.
1384        assert_eq!(money.band_count(12).band_slots(), 12);
1385    }
1386
1387    #[test]
1388    fn a_band_count_keeps_each_bar_in_its_band() {
1389        use gpui::{Bounds, point, px, size};
1390
1391        use super::BarChart;
1392        use crate::plot::{AxisLabelPlacement, scale::Scale};
1393
1394        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(40.), px(100.)));
1395        let chart = |data: &[f64], count| {
1396            BarChart::new(data.to_vec())
1397                .band(|v| format!("{v}"))
1398                .value(|v| *v)
1399                .band_count(count)
1400        };
1401
1402        // Two bars laid out for four bands take the first half of the width.
1403        let wide = chart(&[1., 2.], 2).band_scale(bounds).unwrap();
1404        let narrow = chart(&[1., 2.], 4).band_scale(bounds).unwrap();
1405        assert_eq!(narrow.band_width() * 2., wide.band_width());
1406        assert!(narrow.tick(&"2".to_string()).unwrap() < 20.);
1407
1408        // A bar keeps its place as the data grows into the empty bands.
1409        let grown = chart(&[1., 2., 3.], 4).band_scale(bounds).unwrap();
1410        assert_eq!(grown.tick(&"2".to_string()), narrow.tick(&"2".to_string()));
1411        assert_eq!(grown.band_width(), narrow.band_width());
1412
1413        // Labels inside the plot leave the bars their full width.
1414        let outside = chart(&[1., 2.], 2).value_axis(true);
1415        let inside = chart(&[1., 2.], 2)
1416            .value_axis(true)
1417            .value_axis_label_placement(AxisLabelPlacement::Inside);
1418        assert_eq!(outside.value_axis_gap(), super::VALUE_AXIS_GAP);
1419        assert_eq!(inside.value_axis_gap(), 0.);
1420    }
1421
1422    /// A tooltip row shows its bar's color: a solid fill as is, a
1423    /// `fill_gradient` by its first stop, and the default fill otherwise.
1424    #[gpui::test]
1425    fn the_tooltip_swatch_follows_the_bar_color(cx: &mut gpui::TestAppContext) {
1426        cx.update(crate::init);
1427        let bars = || {
1428            BarChart::new([1., -2.])
1429                .band(|d: &f64| SharedString::from(format!("{d}")))
1430                .value(|d: &f64| *d)
1431        };
1432        let frame = Bounds::default();
1433        let bounds = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)));
1434        let (default, solid, gradient, stops) = cx.update(|cx| {
1435            let gain = gpui::green();
1436            let loss = gpui::red();
1437            let default = bars().bar_color(&1., frame, bounds, cx);
1438            let solid = bars()
1439                .fill(move |d: &f64, _, _, _| if *d >= 0. { gain } else { loss })
1440                .bar_color(&-2., frame, bounds, cx);
1441            let gradient = bars()
1442                .fill(move |_: &f64, _, _, _| {
1443                    linear_gradient(
1444                        0.,
1445                        gpui::linear_color_stop(gain, 0.),
1446                        gpui::linear_color_stop(loss, 1.),
1447                    )
1448                })
1449                .bar_color(&1., frame, bounds, cx);
1450            let stops = bars()
1451                .fill_gradient(move |_: &f64, _, _| {
1452                    [
1453                        gpui::linear_color_stop(gain, 0.),
1454                        gpui::linear_color_stop(loss, 1.),
1455                    ]
1456                })
1457                .bar_color(&1., frame, bounds, cx);
1458            (default, solid, gradient, stops)
1459        });
1460        let chart_2 = cx.update(|cx| cx.theme().chart_2);
1461
1462        assert_eq!(default, chart_2);
1463        assert_eq!(solid, gpui::red());
1464        assert_eq!(gradient, chart_2);
1465        assert_eq!(stops, gpui::green());
1466    }
1467
1468    /// The tooltip reads each bar's frame as `paint` lays it out, so a `fill`
1469    /// that reads the frame colors the swatch as it colors the bar.
1470    #[test]
1471    fn the_tooltip_reads_the_painted_bar_frame() {
1472        let bars = BarChart::new([1., -2.])
1473            .band(|d: &f64| SharedString::from(format!("{d}")))
1474            .value(|d: &f64| *d);
1475        let bounds = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)));
1476        let band_scale = bars.band_scale(bounds).expect("bars have a band scale");
1477        let up = bars.bar_frame(&1., &band_scale, bounds).expect("a frame");
1478        let down = bars.bar_frame(&-2., &band_scale, bounds).expect("a frame");
1479
1480        // Both grow from zero: one up, one down twice as far.
1481        assert_eq!(up.origin.y + up.size.height, down.origin.y);
1482        assert!((down.size.height - 2. * up.size.height).abs() < 0.01);
1483        assert!(up.origin.x < down.origin.x);
1484        assert_eq!(up.size.width, band_scale.band_width());
1485    }
1486}