Skip to main content

gpui_macos/chart/
bar_chart.rs

1use std::{hash::Hash, ops::RangeInclusive, rc::Rc};
2
3use gpui_kit::{
4    AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop,
5    Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px,
6};
7
8use crate::{
9    ActiveTheme,
10    plot::{
11        AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel,
12        label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
13        scale::{PlotValue, Scale, ScaleBand, ScaleLinear},
14        shape::{Bar, BarAlignment},
15        tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
16    },
17};
18
19use super::{
20    AXIS_GAP, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, build_band_labels,
21    caller_id, format_tick, labeled_items, value_axis_gap,
22};
23
24/// How much the bars away from the hovered one fade, as a share of their opacity.
25const HOVER_DIM: f32 = 0.45;
26
27/// The hover a bar chart paints, sampled once per frame in [`Plot::hover`].
28#[derive(Clone, Copy)]
29struct BarHover {
30    /// Cross-axis center of the highlight band, gliding between bars.
31    center: f32,
32    /// How far the hover has faded in.
33    focus: f32,
34}
35
36pub struct BarChart<T, B, V>
37where
38    T: 'static,
39    B: Eq + Hash + Into<SharedString> + 'static,
40    V: PlotValue,
41{
42    data: Vec<T>,
43    band: Option<Rc<dyn Fn(&T) -> B>>,
44    value: Option<Rc<dyn Fn(&T) -> V>>,
45    fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
46    #[allow(clippy::type_complexity)]
47    fill_gradient:
48        Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
49    tick_margin: usize,
50    label: Option<Rc<dyn Fn(&T) -> SharedString>>,
51    label_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
52    label_axis: bool,
53    value_axis: bool,
54    value_axis_label_placement: AxisLabelPlacement,
55    value_tick_count: usize,
56    value_tick_format: Option<TickFormat>,
57    band_count: Option<usize>,
58    band_tick_count: Option<usize>,
59    grid: bool,
60    grid_dashed: bool,
61    alignment: BarAlignment,
62    corner_radii: Corners<Pixels>,
63    padding_inner: f32,
64    padding_outer: f32,
65    max_band_width: Pixels,
66    min_length: f32,
67    id: ElementId,
68    interactive: bool,
69    name: Option<SharedString>,
70    tooltip_content: TooltipContent<T>,
71    /// The label gaps of horizontal bars, measured in `prepaint` for the frame,
72    /// so `tooltip_state` (which has no window) can keep the hover off the labels.
73    horizontal_gaps: (f32, f32),
74    /// The value-axis gutter of vertical bars, measured in `prepaint`; see
75    /// [`value_axis_gap`].
76    value_label_gap: f32,
77    hover: Option<BarHover>,
78}
79
80impl<T, B, V> BarChart<T, B, V>
81where
82    B: Eq + Hash + Into<SharedString> + 'static,
83    V: PlotValue,
84{
85    #[track_caller]
86    pub fn new<I>(data: I) -> Self
87    where
88        I: IntoIterator<Item = T>,
89    {
90        Self {
91            data: data.into_iter().collect(),
92            band: None,
93            value: None,
94            fill: None,
95            fill_gradient: None,
96            tick_margin: 1,
97            label: None,
98            label_color: None,
99            label_axis: true,
100            value_axis: false,
101            value_axis_label_placement: AxisLabelPlacement::default(),
102            value_tick_count: 5,
103            value_tick_format: None,
104            band_count: None,
105            band_tick_count: None,
106            grid: true,
107            grid_dashed: false,
108            alignment: BarAlignment::default(),
109            corner_radii: Corners::all(px(0.)),
110            padding_inner: 0.4,
111            padding_outer: 0.2,
112            max_band_width: px(MAX_BAND_WIDTH),
113            min_length: 0.,
114            id: caller_id(),
115            interactive: true,
116            name: None,
117            tooltip_content: TooltipContent::default(),
118            horizontal_gaps: (0., 0.),
119            value_label_gap: VALUE_AXIS_GAP,
120            hover: None,
121        }
122    }
123
124    /// Name this chart's [`ElementId`], replacing the default taken from the
125    /// construction site.
126    ///
127    /// Pass one where a single construction site renders several of these
128    /// charts as siblings: they share the default id, and with it one hover
129    /// state and one path cache. The id must be unique among those siblings.
130    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
131        self.id = id.into();
132        self
133    }
134
135    /// Turn this chart's interactive layer on or off. On by default.
136    ///
137    /// The layer is the hitbox under the cursor and what it drives: a crosshair
138    /// marks the hovered band, and a tooltip shows its category and value. Turn
139    /// it off for a chart that only decorates, or one an element above it wants
140    /// the cursor for: without a hitbox it neither answers the mouse nor takes
141    /// the hover from what sits over it. A chart that is off also drops its path
142    /// cache, which is keyed on the same id.
143    pub fn interactive(mut self, interactive: bool) -> Self {
144        self.interactive = interactive;
145        self
146    }
147
148    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
149    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
150        self.name = Some(name.into());
151        self
152    }
153
154    /// Set the hover tooltip's title for a datum, instead of its band value.
155    pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
156        self.tooltip_content.set_title(title);
157        self
158    }
159
160    /// Set the text of the tooltip row's value; the raw number by default.
161    ///
162    /// The closure receives the datum and the value the row reads.
163    pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
164        self.tooltip_content.set_value(move |d, _, v| value(d, v));
165        self
166    }
167
168    /// Color the tooltip row's value, such as green or red by its sign; the
169    /// tooltip's text color by default.
170    ///
171    /// The closure receives the same arguments as
172    /// [`tooltip_value`](Self::tooltip_value).
173    pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
174    where
175        H: Into<Hsla>,
176    {
177        self.tooltip_content
178            .set_value_color(move |d, _, value| color(d, value));
179        self
180    }
181
182    /// Draw the tooltip box's content for a datum yourself, in place of the
183    /// title and rows, for a layout they cannot express such as a table.
184    ///
185    /// The highlight band and where the box sits stay the chart's, and
186    /// [`tooltip_title`](Self::tooltip_title), [`tooltip_value`](Self::tooltip_value)
187    /// and [`tooltip_value_color`](Self::tooltip_value_color) no longer apply.
188    pub fn tooltip_content<E>(
189        mut self,
190        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
191    ) -> Self
192    where
193        E: IntoElement,
194    {
195        self.tooltip_content.set_content(content);
196        self
197    }
198
199    /// Map each datum to its band-axis value (the categorical/ordinal axis).
200    pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
201        self.band = Some(Rc::new(band));
202        self
203    }
204
205    /// Map each datum to its numeric value along the value axis.
206    pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
207        self.value = Some(Rc::new(value));
208        self
209    }
210
211    /// Set a per-datum verbatim fill.
212    ///
213    /// The closure receives:
214    ///
215    /// 1. the datum,
216    /// 2. the **bar's bounds** in pixel space, expressed relative to the
217    ///    chart's origin (i.e. the bar's painted rectangle within the chart),
218    /// 3. the **chart's bounds** in pixel space with origin `(0, 0)` and size
219    ///    equal to the full chart extent, and
220    /// 4. the bar's [`BarAlignment`] (so callers can branch on orientation,
221    ///    e.g. flip a gradient angle).
222    ///
223    /// Both rectangles share the same coordinate system, so callers can
224    /// implement arbitrary chart-aware backgrounds — bar-local gradients,
225    /// chart-wide gradients, patterns, sampled colormaps, etc. — without any
226    /// help from the library.
227    ///
228    /// Accepts any type convertible to [`Background`]. Setting this clears any
229    /// previously set [`BarChart::fill_gradient`].
230    pub fn fill<Bg>(
231        mut self,
232        fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
233    ) -> Self
234    where
235        Bg: Into<Background> + 'static,
236    {
237        self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
238            fill(t, bar_bounds, chart_bounds, alignment).into()
239        }));
240        self.fill_gradient = None;
241        self
242    }
243
244    /// Set a per-datum auto-oriented linear gradient fill.
245    ///
246    /// The closure receives the datum, the chart's full data range
247    /// (`chart_range`, derived from all data values), and a `chart_to_bar`
248    /// remap helper that maps a chart-value coordinate to a bar-local
249    /// gradient position (where `0.0` is the bar's base and `1.0` is its tip).
250    ///
251    /// Use bar-local positions directly for per-bar gradients (every bar
252    /// looks the same regardless of its value):
253    ///
254    /// ```ignore
255    /// .fill_gradient(|_, _, _| [
256    ///     linear_color_stop(c.opacity(0.3), 0.0),
257    ///     linear_color_stop(c, 1.0),
258    /// ])
259    /// ```
260    ///
261    /// Or use `chart_to_bar` to position stops at chart-relative values, so
262    /// each bar shows the slice of a chart-wide gradient corresponding to
263    /// its own `[base, value]` span:
264    ///
265    /// ```ignore
266    /// .fill_gradient(|_, chart_range, chart_to_bar| [
267    ///     linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())),
268    ///     linear_color_stop(c,              chart_to_bar(*chart_range.end())),
269    /// ])
270    /// ```
271    ///
272    /// Stop positions returned outside `[0, 1]` are clipped to the bar; the
273    /// library interpolates colors at the clip points so the on-bar gradient
274    /// still matches the chart-wide one.
275    ///
276    /// The gradient angle is derived from [`BarAlignment`] so stop-0 is at the
277    /// base and stop-1 at the tip. Setting this clears any previously set
278    /// [`BarChart::fill`].
279    pub fn fill_gradient(
280        mut self,
281        fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
282    ) -> Self {
283        self.fill_gradient = Some(Rc::new(fill));
284        self.fill = None;
285        self
286    }
287
288    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
289        self.tick_margin = tick_margin;
290        self
291    }
292
293    pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
294    where
295        S: Into<SharedString> + 'static,
296    {
297        self.label = Some(Rc::new(move |t| label(t).into()));
298        self
299    }
300
301    /// Color each bar's [`label`](Self::label) text, instead of the theme's
302    /// foreground for all of them.
303    ///
304    /// Takes a closure per bar, as [`fill`](Self::fill) does, so a label can
305    /// follow its bar's color.
306    pub fn label_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
307    where
308        H: Into<Hsla> + 'static,
309    {
310        self.label_color = Some(Rc::new(move |t| color(t).into()));
311        self
312    }
313
314    /// Show or hide the band-axis line and labels.
315    ///
316    /// Default is true.
317    pub fn label_axis(mut self, label_axis: bool) -> Self {
318        self.label_axis = label_axis;
319        self
320    }
321
322    /// Show or hide the value-axis tick labels.
323    ///
324    /// Placed [`Outside`](AxisLabelPlacement::Outside), the default, the labels
325    /// take a gutter along the band axis, left of vertical bars and below
326    /// horizontal ones.
327    ///
328    /// Default is false.
329    pub fn value_axis(mut self, value_axis: bool) -> Self {
330        self.value_axis = value_axis;
331        self
332    }
333
334    /// Set how many ticks the value axis carries, evenly spaced from the
335    /// baseline to the far edge with both ends included, which drives both the
336    /// grid lines and the value-axis tick labels.
337    ///
338    /// Unlike [`Self::tick_margin`], a stride over the band axis categories,
339    /// this counts the ticks themselves. Values below 2 are raised to 2.
340    ///
341    /// Default is 5.
342    pub fn value_tick_count(mut self, count: usize) -> Self {
343        self.value_tick_count = count.max(2);
344        self
345    }
346
347    /// Set where the value-axis tick labels sit: in a gutter beside the bars,
348    /// or inside the plot beside their grid lines, which keeps the bars' room.
349    ///
350    /// Default is [`AxisLabelPlacement::Outside`].
351    pub fn value_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
352        self.value_axis_label_placement = placement;
353        self
354    }
355
356    /// Set the text of each value-axis tick label from the value at its tick.
357    ///
358    /// Default is whole numbers bare and the rest to one decimal.
359    pub fn value_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
360    where
361        S: Into<SharedString> + 'static,
362    {
363        self.value_tick_format = Some(Rc::new(move |value| format(value).into()));
364        self
365    }
366
367    /// Lay the band axis out for `count` bands instead of the data's own
368    /// length.
369    ///
370    /// The data takes the leading bands in order and the rest stay empty, so
371    /// each bar keeps its width and place as the data grows. A `count` below
372    /// the data's length has no effect.
373    pub fn band_count(mut self, count: usize) -> Self {
374        self.band_count = Some(count);
375        self
376    }
377
378    /// Label `count` of the bands, spread evenly from the first to the last,
379    /// instead of every `tick_margin`-th.
380    ///
381    /// With [`Self::band_count`] set, the labels spread over all the bands, so
382    /// they keep their places as the data grows; one that falls on an empty band
383    /// is not drawn yet.
384    pub fn band_tick_count(mut self, count: usize) -> Self {
385        self.band_tick_count = Some(count);
386        self
387    }
388
389    pub fn grid(mut self, grid: bool) -> Self {
390        self.grid = grid;
391        self
392    }
393
394    /// Draw the grid dashed or solid.
395    ///
396    /// Default is false: a solid divider hairline, like the shell's separators.
397    pub fn grid_dashed(mut self, dashed: bool) -> Self {
398        self.grid_dashed = dashed;
399        self
400    }
401
402    /// Set the bar alignment.
403    ///
404    /// Default is [`BarAlignment::Bottom`].
405    pub fn alignment(mut self, alignment: BarAlignment) -> Self {
406        self.alignment = alignment;
407        self
408    }
409
410    /// Set the corner radii applied to every bar rectangle.
411    ///
412    /// Use [`Corners::all`] for uniform rounding, or construct [`Corners`] manually
413    /// to round only specific corners (e.g. just the tip end of each bar).
414    pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
415        self.corner_radii = corner_radii.into();
416        self
417    }
418
419    /// Set the gap between neighbouring bars, as a share of each band.
420    ///
421    /// Default is 0.4.
422    pub fn padding_inner(mut self, padding: f32) -> Self {
423        self.padding_inner = padding;
424        self
425    }
426
427    /// Set the gap before the first bar and after the last, as a share of a band.
428    ///
429    /// Default is 0.2.
430    pub fn padding_outer(mut self, padding: f32) -> Self {
431        self.padding_outer = padding;
432        self
433    }
434
435    /// Keep every bar at most `width` wide, so a few bars across a wide chart
436    /// stay narrow instead of filling their bands.
437    ///
438    /// Default is 30px.
439    pub fn max_band_width(mut self, width: impl Into<Pixels>) -> Self {
440        self.max_band_width = width.into();
441        self
442    }
443
444    /// Draw every bar at least `length` pixels long, so a zero or tiny value
445    /// still shows a stub instead of disappearing into the baseline.
446    ///
447    /// The stub grows the way the bar's value would: away from the zero line,
448    /// to the negative side for a negative value and to the positive side for
449    /// zero. A bar already that long is left alone.
450    ///
451    /// Default is 0.
452    pub fn min_length(mut self, length: f32) -> Self {
453        self.min_length = length;
454        self
455    }
456
457    /// The band scale (matching `paint`): spans the height for horizontal bars, the width
458    /// otherwise. Shared by `tooltip_state` and `tooltip`.
459    fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
460        let band_fn = self.band.as_ref()?;
461        let band_extent = if self.alignment.is_horizontal() {
462            bounds.size.height.as_f32()
463        } else {
464            bounds.size.width.as_f32()
465        };
466        // Value-axis labels eat into the band extent at one end; `band_offset`
467        // shifts the bands away from that end when it is the leading one.
468        let extent = (band_extent - self.value_axis_gap()).max(0.);
469        Some(
470            ScaleBand::new(self.data.iter().map(|v| band_fn(v)), [0., extent])
471                .band_count(self.band_count.unwrap_or(0))
472                .max_band_width(self.max_band_width.as_f32())
473                .padding_inner(self.padding_inner)
474                .padding_outer(self.padding_outer),
475        )
476    }
477
478    /// Offset added to every band-scale tick.
479    ///
480    /// [`ScaleBand`] ignores the start of its range, so vertical bars are shifted
481    /// by hand to clear the value-axis labels on their left. Horizontal bars put
482    /// those labels below the plot, past the end of the band axis, so they need no
483    /// shift.
484    fn band_offset(&self) -> f32 {
485        if self.alignment.is_horizontal() {
486            0.
487        } else {
488            self.value_axis_gap()
489        }
490    }
491
492    /// The value axis for `bounds`: the scale the bars grow along, and the
493    /// pixel positions of its baseline and far edge. `paint` lays the bars out
494    /// on it and the tooltip reads the hovered bar's frame from it.
495    fn value_scale(&self, bounds: Bounds<Pixels>) -> Option<(ScaleLinear<V>, f32, f32)> {
496        let value_fn = self.value.as_ref()?;
497        let value_dim = if self.alignment.is_horizontal() {
498            bounds.size.width.as_f32()
499        } else {
500            bounds.size.height.as_f32()
501        };
502        let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
503        // For horizontal charts the band labels (category names) are rendered
504        // along the value axis and can be arbitrarily wide, so we measure the
505        // actual maximum label width instead of using a fixed constant.
506        // Similarly, value labels (numbers) at the bar ends are measured so the
507        // scale range is always shrunk by exactly the right amount.
508        // Vertical bars keep a line of text clear past the tallest bar when they
509        // carry value labels, so the label above it stays inside the chart.
510        let far_gap = if self.label.is_some() {
511            TEXT_HEIGHT
512        } else {
513            10.
514        };
515        let (band_gap, value_end_gap) = if self.alignment.is_horizontal() {
516            self.horizontal_gaps
517        } else {
518            (axis_gap, far_gap)
519        };
520        // The baseline, and the far edge opposite it.
521        let (baseline, far) = match self.alignment {
522            BarAlignment::Bottom => (value_dim - axis_gap, far_gap),
523            BarAlignment::Top => (axis_gap, value_dim - far_gap),
524            BarAlignment::Left => (band_gap, value_dim - value_end_gap),
525            BarAlignment::Right => (value_dim - band_gap, value_end_gap),
526        };
527        let scale = ScaleLinear::new(
528            self.data.iter().map(|v| value_fn(v)).chain(Some(V::zero())),
529            [baseline, far],
530        );
531        Some((scale, baseline, far))
532    }
533
534    /// The frame `paint` gives datum `d`'s bar, the one `fill` receives.
535    fn bar_frame(
536        &self,
537        d: &T,
538        band_scale: &ScaleBand<B>,
539        bounds: Bounds<Pixels>,
540    ) -> Option<Bounds<f32>> {
541        let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
542        let (value_scale, baseline, _) = self.value_scale(bounds)?;
543        let zero = value_scale.tick(&V::zero()).unwrap_or(baseline);
544        let cross = band_scale.tick(&band_fn(d))? + self.band_offset();
545        let end = bar_end(
546            &value_scale,
547            value_fn(d),
548            zero,
549            self.alignment,
550            self.min_length,
551        )?;
552        let (lo, length) = (end.min(zero), (end - zero).abs());
553        let band_width = band_scale.band_width();
554        Some(if self.alignment.is_horizontal() {
555            Bounds {
556                origin: Point::new(lo, cross),
557                size: Size::new(length, band_width),
558            }
559        } else {
560            Bounds {
561                origin: Point::new(cross, lo),
562                size: Size::new(band_width, length),
563            }
564        })
565    }
566
567    /// The data range `fill_gradient` reads, the same for every bar.
568    fn gradient_range(&self) -> RangeInclusive<f32> {
569        let Some(value_fn) = self.value.as_ref() else {
570            return 0.0..=0.0;
571        };
572        let mut lo = 0.0_f32;
573        let mut hi = 0.0_f32;
574        for v in &self.data {
575            if let Some(f) = value_fn(v).to_f32() {
576                lo = lo.min(f);
577                hi = hi.max(f);
578            }
579        }
580        lo..=hi
581    }
582
583    /// The color a tooltip row shows for datum `d`: its bar's, the first stop
584    /// of a gradient, or the default fill when `fill` returns a gradient,
585    /// whose stops can't be read back. `frame` is the bar's, as `paint` lays it
586    /// out.
587    fn bar_color(&self, d: &T, frame: Bounds<f32>, bounds: Bounds<Pixels>, cx: &App) -> Hsla {
588        let default = cx.macos().chart[0];
589        if let Some(fill) = self.fill_gradient.as_ref() {
590            let value = self
591                .value
592                .as_ref()
593                .and_then(|value_fn| value_fn(d).to_f32())
594                .unwrap_or(0.);
595            let [first, _] = bar_gradient(fill.as_ref(), d, value, self.gradient_range());
596            return first.color;
597        }
598        let Some(fill) = self.fill.as_ref() else {
599            return default;
600        };
601        let chart_bounds = Bounds {
602            origin: Point::new(0., 0.),
603            size: Size::new(bounds.size.width.as_f32(), bounds.size.height.as_f32()),
604        };
605        fill(d, frame, chart_bounds, self.alignment)
606            .as_solid()
607            .unwrap_or(default)
608    }
609
610    /// The gutter the value-axis labels take along the band axis: none unless
611    /// they are shown outside the plot.
612    fn value_axis_gap(&self) -> f32 {
613        if !self.value_axis || self.value_axis_label_placement != AxisLabelPlacement::Outside {
614            0.
615        } else if self.alignment.is_horizontal() {
616            // Below the plot, where the gap is a line of text tall.
617            VALUE_AXIS_GAP
618        } else {
619            self.value_label_gap
620        }
621    }
622
623    /// The bands the band axis is laid out for: the data's, or the
624    /// [`Self::band_count`] when larger.
625    fn band_slots(&self) -> usize {
626        self.band_count.unwrap_or(0).max(self.data.len())
627    }
628
629    /// The value-axis tick label text, from the domain maximum at the far end
630    /// down to the minimum at the baseline, matching the value scale.
631    fn value_tick_labels(&self) -> Vec<SharedString> {
632        let Some(value_fn) = self.value.as_ref() else {
633            return vec![];
634        };
635        // The data plus zero, as `value_scale` spans.
636        let (lo, hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
637            let f = value_fn(v).to_f32().unwrap_or(0.);
638            (lo.min(f), hi.max(f))
639        });
640        let steps = (self.value_tick_count - 1) as f32;
641        (0..self.value_tick_count)
642            .map(|i| {
643                let value = (hi - (hi - lo) * i as f32 / steps) as f64;
644                match self.value_tick_format.as_ref() {
645                    Some(format) => format(value),
646                    None => format_tick(value),
647                }
648            })
649            .collect()
650    }
651
652    /// Label gaps `(band_side, value_end_side)` reserved along the value axis for
653    /// horizontal bars, measured from the actual label text. Measured once per frame
654    /// in `prepaint` and kept in `horizontal_gaps`, so `paint` and the tooltip share
655    /// one measurement and the crosshair lines up with the bar region.
656    fn measure_horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
657        let Some(band_fn) = self.band.as_ref() else {
658            return (0., 0.);
659        };
660        let font_size = px(TEXT_SIZE);
661        let band_gap = if self.label_axis {
662            self.data
663                .iter()
664                .map(|v| {
665                    let s: SharedString = band_fn(v).into();
666                    measure_text_width(&s, font_size, window)
667                })
668                .fold(0f32, f32::max)
669                + TEXT_GAP * 2.
670        } else {
671            0.
672        };
673        let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
674            self.data
675                .iter()
676                .map(|v| measure_text_width(&label_fn(v), font_size, window))
677                .fold(0f32, f32::max)
678                + TEXT_GAP * 2.
679        } else {
680            TEXT_GAP * 4.
681        };
682        (band_gap, value_end_gap)
683    }
684
685    /// The extent `(start, length)` of the bars along the value axis, which the
686    /// hover is confined to so the axis labels never show a tooltip.
687    fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
688        if self.alignment.is_horizontal() {
689            let (band_gap, value_end_gap) = self.horizontal_gaps;
690            let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
691            let start = if matches!(self.alignment, BarAlignment::Left) {
692                band_gap
693            } else {
694                value_end_gap
695            };
696            (start, length)
697        } else {
698            let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
699            let length = bounds.size.height.as_f32() - axis_gap;
700            let start = if matches!(self.alignment, BarAlignment::Top) {
701                axis_gap
702            } else {
703                0.
704            };
705            (start, length)
706        }
707    }
708
709    /// Whether the cursor is over a bar's row or column rather than the axis labels.
710    fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
711        let (start, length) = self.value_extent(bounds);
712        if self.alignment.is_horizontal() {
713            let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
714            (start..=start + length).contains(&position.x.as_f32())
715                && !(self.value_axis_gap() > 0. && position.y.as_f32() > value_labels_top)
716        } else {
717            (start..=start + length).contains(&position.y.as_f32())
718                && position.x.as_f32() >= self.band_offset()
719        }
720    }
721}
722
723crate::impl_into_plot!(impl<T, B, V> BarChart<T, B, V> where B: Eq + Hash + Into<SharedString> + 'static, V: PlotValue);
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.macos().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.macos().secondary,
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.macos().secondary,
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.macos().divider());
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.macos().secondary;
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.macos().chart[0].into();
930        let fill = self.fill.clone();
931        let fill_gradient = self.fill_gradient.clone();
932        let label_color = cx.macos().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_kit::{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_kit::test]
1383    fn the_tooltip_swatch_follows_the_bar_color(cx: &mut gpui_kit::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_kit::size(px(100.), px(100.)));
1392        let (default, solid, gradient, stops) = cx.update(|cx| {
1393            let gain = gpui_kit::green();
1394            let loss = gpui_kit::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_kit::linear_color_stop(gain, 0.),
1404                        gpui_kit::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_kit::linear_color_stop(gain, 0.),
1412                        gpui_kit::linear_color_stop(loss, 1.),
1413                    ]
1414                })
1415                .bar_color(&1., frame, bounds, cx);
1416            (default, solid, gradient, stops)
1417        });
1418        let accent = cx.update(|cx| cx.macos().chart[0]);
1419
1420        assert_eq!(default, accent);
1421        assert_eq!(solid, gpui_kit::red());
1422        assert_eq!(gradient, accent);
1423        assert_eq!(stops, gpui_kit::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_kit::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}