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