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            let labeled = labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin);
663            self.data
664                .iter()
665                .enumerate()
666                .filter(|(i, _)| labeled.get(*i).copied().unwrap_or(false))
667                .map(|(_, v)| {
668                    let s: SharedString = band_fn(v).into();
669                    measure_text_width(&s, font_size, window)
670                })
671                .fold(0f32, f32::max)
672                + TEXT_GAP * 2.
673        } else {
674            0.
675        };
676        let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
677            self.data
678                .iter()
679                .map(|v| measure_text_width(&label_fn(v), font_size, window))
680                .fold(0f32, f32::max)
681                + TEXT_GAP * 2.
682        } else {
683            TEXT_GAP * 4.
684        };
685        (band_gap, value_end_gap)
686    }
687
688    /// The extent `(start, length)` of the bars along the value axis, which the
689    /// hover is confined to so the axis labels never show a tooltip.
690    fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
691        if self.alignment.is_horizontal() {
692            let (band_gap, value_end_gap) = self.horizontal_gaps;
693            let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
694            let start = if matches!(self.alignment, BarAlignment::Left) {
695                band_gap
696            } else {
697                value_end_gap
698            };
699            (start, length)
700        } else {
701            let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
702            let length = bounds.size.height.as_f32() - axis_gap;
703            let start = if matches!(self.alignment, BarAlignment::Top) {
704                axis_gap
705            } else {
706                0.
707            };
708            (start, length)
709        }
710    }
711
712    /// Whether the cursor is over a bar's row or column rather than the axis labels.
713    fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
714        let (start, length) = self.value_extent(bounds);
715        if self.alignment.is_horizontal() {
716            let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
717            (start..=start + length).contains(&position.x.as_f32())
718                && !(self.value_axis_gap() > 0. && position.y.as_f32() > value_labels_top)
719        } else {
720            (start..=start + length).contains(&position.y.as_f32())
721                && position.x.as_f32() >= self.band_offset()
722        }
723    }
724}
725
726crate::impl_into_plot!(impl<T, B, V> BarChart<T, B, V> where B: Eq + Hash + Into<SharedString> + 'static, V: PlotValue);
727
728impl<T, B, V> Plot for BarChart<T, B, V>
729where
730    B: Eq + Hash + Into<SharedString> + 'static,
731    V: PlotValue,
732{
733    fn prepaint(
734        &mut self,
735        _bounds: Bounds<Pixels>,
736        window: &mut Window,
737        _cx: &mut App,
738    ) -> Vec<AnyElement> {
739        self.horizontal_gaps = if self.alignment.is_horizontal() {
740            self.measure_horizontal_gaps(window)
741        } else {
742            (0., 0.)
743        };
744        if self.value_axis
745            && self.value_axis_label_placement == AxisLabelPlacement::Outside
746            && !self.alignment.is_horizontal()
747        {
748            self.value_label_gap = value_axis_gap(self.value_tick_labels(), window);
749        }
750        vec![]
751    }
752
753    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
754        let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
755            return;
756        };
757
758        let total_width = bounds.size.width.as_f32();
759        let total_height = bounds.size.height.as_f32();
760        let alignment = self.alignment;
761        let is_horizontal = alignment.is_horizontal();
762
763        // Band scale spans the full extent perpendicular to the value axis. Shared with the
764        // tooltip via `band_scale()` so the bars and the hover crosshair stay aligned.
765        let Some(band_scale) = self.band_scale(bounds) else {
766            return;
767        };
768        let band_width = band_scale.band_width();
769
770        let Some((value_scale, baseline, far)) = self.value_scale(bounds) else {
771            return;
772        };
773
774        // Where zero sits along the value axis. Bars grow from here rather than from
775        // the geometric baseline, so negative values extend to the opposite side. With
776        // no negative data zero is the domain minimum and this is the baseline.
777        let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
778        let band_offset = self.band_offset();
779
780        // Grid lines and the zero line span their bounds edge to edge, so they are
781        // painted into bounds inset by the value-axis gap. Without this they run
782        // straight through the value-axis labels.
783        let value_axis_gap = self.value_axis_gap();
784        let plot_bounds = if is_horizontal {
785            Bounds {
786                origin: bounds.origin,
787                size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
788            }
789        } else {
790            Bounds {
791                origin: bounds.origin + point(px(value_axis_gap), px(0.)),
792                size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
793            }
794        };
795
796        // Draw band axis (with categorical labels).
797        let mut axis = PlotAxis::new().stroke(cx.macos().border);
798        if self.label_axis {
799            match alignment {
800                BarAlignment::Bottom | BarAlignment::Top => {
801                    axis = axis.x(zero_pixel);
802
803                    // Labels are placed one at a time rather than through
804                    // `x_label`, because a chart with negative values needs them
805                    // on either side of the zero line: each label goes on the side
806                    // its own bar leaves empty.
807                    let labeled =
808                        labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin);
809                    let labels = self
810                        .data
811                        .iter()
812                        .enumerate()
813                        .filter(|(i, _)| labeled[*i])
814                        .filter_map(|(_, d)| {
815                            let band_x = band_scale.tick(&band_fn(d))?;
816                            let value = value_fn(d).to_f32().unwrap_or(0.);
817                            let label_y = if label_below_zero_line(value, alignment) {
818                                zero_pixel + TEXT_GAP
819                            } else {
820                                zero_pixel - TEXT_GAP - TEXT_SIZE
821                            };
822
823                            Some(
824                                Text::new(
825                                    band_fn(d).into(),
826                                    point(px(band_x + band_offset + band_width / 2.), px(label_y)),
827                                    cx.macos().secondary,
828                                )
829                                .align(TextAlign::Center),
830                            )
831                        })
832                        .collect();
833                    PlotLabel::new(labels).paint(&bounds, window, cx);
834                }
835                BarAlignment::Left | BarAlignment::Right => {
836                    let labels = build_band_labels(
837                        &self.data,
838                        band_fn.as_ref(),
839                        &band_scale,
840                        band_width,
841                        &labeled_items(self.band_slots(), self.band_tick_count, self.tick_margin),
842                        cx.macos().secondary,
843                    );
844                    let (side, align) = if matches!(alignment, BarAlignment::Left) {
845                        (AxisLabelSide::Start, TextAlign::Right)
846                    } else {
847                        (AxisLabelSide::End, TextAlign::Left)
848                    };
849                    axis = axis
850                        .y(zero_pixel)
851                        .y_label_side(side)
852                        .y_label(labels.into_iter().map(|t| t.align(align)));
853                }
854            }
855        }
856        axis.paint(&plot_bounds, window, cx);
857
858        let value_ticks = value_tick_positions(far, baseline, self.value_tick_count);
859        let steps = value_ticks.len() - 1;
860
861        // Draw grid, excluding the line at the baseline.
862        if self.grid {
863            let grid = Grid::new().stroke(cx.macos().divider());
864            let grid = if self.grid_dashed {
865                grid.dash_array(&[px(4.), px(2.)])
866            } else {
867                grid
868            };
869            let lines = value_ticks[..steps].to_vec();
870            let grid = if is_horizontal {
871                grid.x(lines)
872            } else {
873                grid.y(lines)
874            };
875            grid.paint(&plot_bounds, window);
876        }
877
878        // Labels inside the plot are painted after the bars, so no bar covers them.
879        let mut inside_labels = None;
880        if self.value_axis {
881            // Ticks run from `far` (the domain maximum) to `baseline` (the minimum),
882            // so the labels walk the domain in the same direction.
883            let color = cx.macos().secondary;
884            let texts = self
885                .value_tick_labels()
886                .into_iter()
887                .zip(value_ticks.iter().copied());
888
889            match self.value_axis_label_placement {
890                // The labels go in the gap `band_scale` kept clear for them,
891                // right-aligned against the plot area for vertical bars and centred
892                // under it otherwise.
893                AxisLabelPlacement::Outside => {
894                    let labels = texts.map(|(text, tick)| AxisText::new(text, px(tick), color));
895                    let value_axis = if is_horizontal {
896                        PlotAxis::new()
897                            .x_axis(false)
898                            .x(px(total_height - VALUE_AXIS_GAP))
899                            .x_label(labels.map(|t| t.align(TextAlign::Center)))
900                    } else {
901                        PlotAxis::new()
902                            .y_axis(false)
903                            .y(px(value_axis_gap - TEXT_GAP * 2.))
904                            .y_label(labels.map(|t| t.align(TextAlign::Right)))
905                    };
906                    value_axis.paint(&bounds, window, cx);
907                }
908                // Over the plot beside each grid line: above it for vertical bars
909                // but for the topmost, which would leave the plot, and along the
910                // bottom edge for horizontal ones.
911                AxisLabelPlacement::Inside => {
912                    let labels = texts
913                        .map(|(text, tick)| {
914                            if is_horizontal {
915                                Text::new(text, point(tick, total_height - TEXT_HEIGHT), color)
916                                    .align(TextAlign::Center)
917                            } else {
918                                let top = if tick < TEXT_HEIGHT {
919                                    tick + TEXT_GAP
920                                } else {
921                                    tick - TEXT_HEIGHT
922                                };
923                                Text::new(text, point(TEXT_GAP, top), color)
924                            }
925                        })
926                        .collect();
927                    inside_labels = Some(PlotLabel::new(labels));
928                }
929            }
930        }
931
932        // Draw bars.
933        let band_fn_cloned = band_fn.clone();
934        let value_fn_cloned = value_fn.clone();
935        let default_fill: Background = cx.macos().chart[0].into();
936        let fill = self.fill.clone();
937        let fill_gradient = self.fill_gradient.clone();
938        let label_color = cx.macos().foreground;
939        let label_color_fn = self.label_color.clone();
940        let min_length = self.min_length;
941
942        // Chart bounds in pixel space, with origin (0, 0) and size equal to
943        // the full chart extent. Passed to user `fill` closures so they can
944        // position chart-wide backgrounds (gradients, patterns, etc.).
945        let chart_bounds: Bounds<f32> = Bounds {
946            origin: Point::new(0., 0.),
947            size: Size::new(total_width, total_height),
948        };
949
950        // Chart data range in f32 — passed to `fill_gradient` callers and used
951        // by the `chart_to_bar` remap helper.
952        let chart_range = if fill_gradient.is_some() {
953            self.gradient_range()
954        } else {
955            0.0..=0.0
956        };
957
958        // The hovered bar keeps its color while the others fade behind it. The
959        // highlight band springs between bars, so each bar's emphasis follows the
960        // band's distance from it and the focus hands over as the band slides.
961        let hover = self.hover;
962        let step = band_scale.step().max(f32::EPSILON);
963        let emphasis = move |frame: Bounds<f32>| -> f32 {
964            let Some(hover) = hover else {
965                return 1.;
966            };
967            let center = if is_horizontal {
968                frame.origin.y + frame.size.height / 2.
969            } else {
970                frame.origin.x + frame.size.width / 2.
971            };
972            let distance = ((center - hover.center).abs() / step).min(1.);
973            1. - HOVER_DIM * hover.focus * distance
974        };
975
976        let mut bar = Bar::new()
977            .data(&self.data)
978            .alignment(alignment)
979            .band_width(band_width)
980            .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
981            .base(move |_| zero_pixel)
982            .value(move |d| {
983                bar_end(
984                    &value_scale,
985                    value_fn_cloned(d),
986                    zero_pixel,
987                    alignment,
988                    min_length,
989                )
990            })
991            .corner_radii(self.corner_radii);
992
993        bar = match (fill, fill_gradient) {
994            (_, Some(fg)) => {
995                let value_fn_for_grad = value_fn.clone();
996                bar.fill(move |d, frame, alignment| {
997                    let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
998                    let [s0, s1] = bar_gradient(fg.as_ref(), d, v, chart_range.clone());
999                    let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
1000                    bg.opacity(emphasis(frame))
1001                })
1002            }
1003            (Some(f), _) => bar.fill(move |d, frame, alignment| {
1004                f(d, frame, chart_bounds, alignment).opacity(emphasis(frame))
1005            }),
1006            _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))),
1007        };
1008
1009        if let Some(label) = self.label.as_ref() {
1010            let label = label.clone();
1011            let text_align = match alignment {
1012                BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
1013                BarAlignment::Left => TextAlign::Left,
1014                BarAlignment::Right => TextAlign::Right,
1015            };
1016            bar = bar.label(move |d, p| {
1017                let color = label_color_fn.as_ref().map_or(label_color, |f| f(d));
1018                vec![Text::new(label(d), p, color).align(text_align)]
1019            });
1020        }
1021
1022        bar.paint(&bounds, window, cx);
1023        if let Some(labels) = inside_labels {
1024            labels.paint(&bounds, window, cx);
1025        }
1026    }
1027
1028    fn id(&self) -> Option<ElementId> {
1029        self.interactive.then(|| self.id.clone())
1030    }
1031
1032    fn tooltip_state(
1033        &self,
1034        position: Point<Pixels>,
1035        bounds: Bounds<Pixels>,
1036        _cx: &App,
1037    ) -> Option<TooltipState> {
1038        let band_fn = self.band.as_ref()?;
1039        self.value.as_ref()?;
1040
1041        // Skip the tooltip when the cursor is over the axis labels, not a bar.
1042        if !self.is_over_bars(position, bounds) {
1043            return None;
1044        }
1045
1046        // Only the band scale is needed to hit-test which bar is hovered; the label
1047        // gaps were measured in `prepaint`, so no `window` is required here.
1048        let is_horizontal = self.alignment.is_horizontal();
1049        let band_scale = self.band_scale(bounds)?;
1050        let band_width = band_scale.band_width();
1051
1052        let band_offset = self.band_offset();
1053        let cursor_band = if is_horizontal {
1054            position.y
1055        } else {
1056            position.x
1057        };
1058        let index = band_scale.nearest_index(cursor_band.as_f32() - band_offset);
1059        let d = self.data.get(index)?;
1060        let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
1061
1062        // Vertical bars: vertical crosshair at the bar's x. Horizontal bars: horizontal
1063        // crosshair at the bar's y. The box tracks the cursor either way.
1064        let cross_line = if is_horizontal {
1065            point(position.x, px(center))
1066        } else {
1067            point(px(center), position.y)
1068        };
1069
1070        Some(TooltipState::new(index, cross_line, vec![]))
1071    }
1072
1073    fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
1074        self.hover = hover.map(|hover| {
1075            // The band slides to the hovered bar; on the first hovered frame it
1076            // adopts the bar instead of travelling from where the last hover ended.
1077            let target = if self.alignment.is_horizontal() {
1078                hover.state().cross_line.y
1079            } else {
1080                hover.state().cross_line.x
1081            };
1082            let center = hover.glide(("bar-chart", "band"), target, window, cx);
1083            BarHover {
1084                center: center.as_f32(),
1085                focus: hover.progress(),
1086            }
1087        });
1088    }
1089
1090    fn tooltip(
1091        &self,
1092        state: &TooltipState,
1093        cursor: Point<Pixels>,
1094        bounds: Bounds<Pixels>,
1095        window: &mut Window,
1096        cx: &mut App,
1097    ) -> Option<AnyElement> {
1098        let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
1099        let d = self.data.get(state.index)?;
1100        let name = self.name.clone().unwrap_or_default();
1101
1102        // Highlight the hovered bar with a translucent band the width of the bar, instead
1103        // of a hairline. Confined to the plot area so it doesn't cover the axis labels,
1104        // and centered where the band has glided to, which the other bars also fade by.
1105        let band_scale = self.band_scale(bounds)?;
1106        let band_width = band_scale.band_width();
1107        let center = self.hover.map_or(state.cross_line, |hover| {
1108            if self.alignment.is_horizontal() {
1109                point(state.cross_line.x, px(hover.center))
1110            } else {
1111                point(px(hover.center), state.cross_line.y)
1112            }
1113        });
1114        let (start, length) = self.value_extent(bounds);
1115        let cross_line = if self.alignment.is_horizontal() {
1116            CrossLine::new(center)
1117                .horizontal()
1118                .h_span(start, length)
1119                .band(px(band_width))
1120        } else {
1121            CrossLine::new(center)
1122                .span(start, length)
1123                .band(px(band_width))
1124        };
1125
1126        let frame = self.bar_frame(d, &band_scale, bounds).unwrap_or_default();
1127        let swatch = self.bar_color(d, frame, bounds, cx);
1128
1129        // Follow the cursor; `hover` already glides the band.
1130        let tooltip = Tooltip::new(cursor, bounds.size)
1131            .glide(false)
1132            .gap(px(8.))
1133            .cross_line(cross_line);
1134
1135        let tooltip = self.tooltip_content.apply(
1136            tooltip,
1137            d,
1138            || Some(band_fn(d).into()),
1139            || Some([(swatch, name, value_fn(d).to_f64()?)]),
1140            window,
1141            cx,
1142        )?;
1143
1144        Some(tooltip.into_any_element())
1145    }
1146}
1147
1148/// The end a bar showing `value` reaches along the value axis, at least
1149/// `min_length` pixels from `zero`.
1150fn bar_end<V>(
1151    scale: &ScaleLinear<V>,
1152    value: V,
1153    zero: f32,
1154    alignment: BarAlignment,
1155    min_length: f32,
1156) -> Option<f32>
1157where
1158    V: PlotValue,
1159{
1160    let tick = scale.tick(&value)?;
1161    Some(extend_to_min_length(
1162        tick,
1163        zero,
1164        value < V::zero(),
1165        alignment,
1166        min_length,
1167    ))
1168}
1169
1170/// Push a bar's value end away from `zero` until the bar is `min` pixels long,
1171/// in the direction its value grows for `alignment`.
1172fn extend_to_min_length(
1173    tick: f32,
1174    zero: f32,
1175    negative: bool,
1176    alignment: BarAlignment,
1177    min: f32,
1178) -> f32 {
1179    if (tick - zero).abs() >= min {
1180        return tick;
1181    }
1182    let grows_toward_origin = matches!(alignment, BarAlignment::Bottom | BarAlignment::Right);
1183    if grows_toward_origin != negative {
1184        zero - min
1185    } else {
1186        zero + min
1187    }
1188}
1189
1190/// The two stops `fill` gives datum `d` with bar value `value`, mapped from the
1191/// chart's `range` onto the bar and clipped to it.
1192fn bar_gradient<T>(
1193    fill: &dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2],
1194    d: &T,
1195    value: f32,
1196    range: RangeInclusive<f32>,
1197) -> [LinearColorStop; 2] {
1198    let bar_lo = value.min(0.);
1199    let bar_span = (value.max(0.) - bar_lo).max(f32::EPSILON);
1200    let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
1201    clip_stops_to_bar(fill(d, range, &chart_to_bar))
1202}
1203
1204/// Clip a two-stop gradient to bar-local `[0, 1]`, interpolating colors at the
1205/// clip points so the on-bar gradient matches the (possibly broader) gradient
1206/// the caller defined.
1207///
1208/// When a stop position falls outside `[0, 1]` (e.g. because `chart_to_bar`
1209/// returned a value past the bar's edge for a chart-relative gradient),
1210/// gpui's renderer would clamp the position and lose the gradient effect.
1211/// This function instead replaces such a stop with the color sampled along
1212/// the line through both stops at position `0.0` or `1.0`, preserving the
1213/// visual slice.
1214fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
1215    let [a, b] = stops;
1216    let p0 = a.percentage;
1217    let p1 = b.percentage;
1218    let lerp = |t: f32| -> Hsla {
1219        Hsla {
1220            h: a.color.h + (b.color.h - a.color.h) * t,
1221            s: a.color.s + (b.color.s - a.color.s) * t,
1222            l: a.color.l + (b.color.l - a.color.l) * t,
1223            a: a.color.a + (b.color.a - a.color.a) * t,
1224        }
1225    };
1226    let span = p1 - p0;
1227    let sample = |target: f32| -> Hsla {
1228        if span.abs() < f32::EPSILON {
1229            a.color
1230        } else {
1231            lerp((target - p0) / span)
1232        }
1233    };
1234    let new_a = if (0. ..=1.).contains(&p0) {
1235        a
1236    } else {
1237        LinearColorStop {
1238            color: sample(p0.clamp(0., 1.)),
1239            percentage: p0.clamp(0., 1.),
1240        }
1241    };
1242    let new_b = if (0. ..=1.).contains(&p1) {
1243        b
1244    } else {
1245        LinearColorStop {
1246            color: sample(p1.clamp(0., 1.)),
1247            percentage: p1.clamp(0., 1.),
1248        }
1249    };
1250    [new_a, new_b]
1251}
1252
1253/// Whether a vertical bar's category label belongs below the zero line.
1254///
1255/// A bar grows away from the zero line, so its label goes on the side the bar
1256/// leaves empty. Which side that is flips with both the sign of the value and the
1257/// alignment. A zero-length bar counts as positive, which puts its label in the
1258/// axis gap rather than inside the plot.
1259fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
1260    (value < 0.) == (alignment == BarAlignment::Top)
1261}
1262
1263/// `count` evenly spaced tick positions along the value axis.
1264///
1265/// Runs from `far` (the value domain's maximum) through `baseline` (its minimum)
1266/// inclusive, so the last position is the baseline. `count` is at least 2.
1267fn value_tick_positions(far: f32, baseline: f32, count: usize) -> Vec<f32> {
1268    let steps = (count - 1) as f32;
1269    (0..count)
1270        .map(|i| far + (baseline - far) * i as f32 / steps)
1271        .collect()
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277    use gpui_kit::{Context, Render, TestAppContext, div, prelude::*};
1278    use std::cell::Cell;
1279
1280    struct CountedBars {
1281        value_axis: bool,
1282        reads: Rc<Cell<usize>>,
1283        formats: Rc<Cell<usize>>,
1284    }
1285
1286    impl Render for CountedBars {
1287        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1288            let reads = self.reads.clone();
1289            let formats = self.formats.clone();
1290            div().w(px(640.)).h(px(320.)).child(
1291                BarChart::new(0..2048)
1292                    .interactive(false)
1293                    .band(|index| index.to_string())
1294                    .value(move |index| {
1295                        reads.set(reads.get() + 1);
1296                        *index as f64
1297                    })
1298                    .label_axis(false)
1299                    .grid(false)
1300                    .value_axis(self.value_axis)
1301                    .value_axis_label_placement(AxisLabelPlacement::Inside)
1302                    .value_tick_format(move |value| {
1303                        formats.set(formats.get() + 1);
1304                        value.to_string()
1305                    }),
1306            )
1307        }
1308    }
1309
1310    #[gpui_kit::test]
1311    fn solid_bar_rendering_skips_unused_gradient_and_outside_label_scans(cx: &mut TestAppContext) {
1312        cx.update(crate::init);
1313        for value_axis in [false, true] {
1314            let reads = Rc::new(Cell::new(0));
1315            let formats = Rc::new(Cell::new(0));
1316            let (_, cx) = cx.add_window_view({
1317                let reads = reads.clone();
1318                let formats = formats.clone();
1319                move |_, _| CountedBars {
1320                    value_axis,
1321                    reads,
1322                    formats,
1323                }
1324            });
1325            reads.set(0);
1326            formats.set(0);
1327            cx.update(|window, cx| {
1328                window.refresh();
1329                window.draw(cx).clear(cx);
1330            });
1331            assert!(reads.get() >= 2048, "The complete series must be painted");
1332            assert_eq!(reads.get(), 2048 * if value_axis { 3 } else { 2 });
1333            assert_eq!(formats.get(), if value_axis { 5 } else { 0 });
1334        }
1335    }
1336
1337    #[gpui_kit::test]
1338    fn horizontal_bar_gutters_only_measure_displayed_ticks(cx: &mut TestAppContext) {
1339        cx.update(crate::init);
1340        let cx = cx.add_empty_window();
1341        let measured = Rc::new(std::cell::RefCell::new(Vec::new()));
1342        let observed = measured.clone();
1343        let chart = BarChart::new([0, 1, 2])
1344            .band(move |index| {
1345                observed.borrow_mut().push(*index);
1346                if *index == 1 {
1347                    "A very wide label that is not displayed"
1348                } else {
1349                    "Short"
1350                }
1351            })
1352            .value(|index| *index as f64)
1353            .alignment(BarAlignment::Left)
1354            .band_tick_count(2);
1355        cx.update(|window, _| {
1356            let (gap, _) = chart.measure_horizontal_gaps(window);
1357            assert_eq!(
1358                gap,
1359                measure_text_width(&"Short".into(), px(TEXT_SIZE), window) + TEXT_GAP * 2.
1360            );
1361        });
1362        assert_eq!(*measured.borrow(), [0, 2]);
1363    }
1364
1365    #[test]
1366    fn test_label_below_zero_line() {
1367        // Bottom-aligned: positive bars grow up, leaving the space below free.
1368        assert!(label_below_zero_line(5., BarAlignment::Bottom));
1369        assert!(label_below_zero_line(0., BarAlignment::Bottom));
1370        assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
1371
1372        // Top-aligned bars grow the other way, so the sides swap.
1373        assert!(!label_below_zero_line(5., BarAlignment::Top));
1374        assert!(!label_below_zero_line(0., BarAlignment::Top));
1375        assert!(label_below_zero_line(-5., BarAlignment::Top));
1376    }
1377
1378    #[test]
1379    fn test_value_tick_positions() {
1380        // Both ends are included, so 5 ticks means 4 intervals.
1381        assert_eq!(
1382            value_tick_positions(10., 110., 5),
1383            vec![10., 35., 60., 85., 110.]
1384        );
1385
1386        // Top-aligned charts have the baseline before the far edge.
1387        assert_eq!(value_tick_positions(110., 10., 3), vec![110., 60., 10.]);
1388
1389        assert_eq!(value_tick_positions(0., 50., 2), vec![0., 50.]);
1390    }
1391
1392    #[test]
1393    fn test_min_length_extends_away_from_zero() {
1394        // A zero or tiny bar grows the way a positive one would.
1395        assert_eq!(
1396            extend_to_min_length(100., 100., false, BarAlignment::Bottom, 2.),
1397            98.
1398        );
1399        assert_eq!(
1400            extend_to_min_length(10., 10., false, BarAlignment::Top, 2.),
1401            12.
1402        );
1403        assert_eq!(
1404            extend_to_min_length(10., 10., false, BarAlignment::Left, 2.),
1405            12.
1406        );
1407        assert_eq!(
1408            extend_to_min_length(90., 90., false, BarAlignment::Right, 2.),
1409            88.
1410        );
1411
1412        // A small negative bar grows to the other side of the zero line.
1413        assert_eq!(
1414            extend_to_min_length(50.5, 50., true, BarAlignment::Bottom, 2.),
1415            52.
1416        );
1417
1418        // A bar already long enough is left alone.
1419        assert_eq!(
1420            extend_to_min_length(40., 100., false, BarAlignment::Bottom, 2.),
1421            40.
1422        );
1423    }
1424
1425    #[test]
1426    fn value_tick_labels_walk_the_domain_from_the_far_end() {
1427        use super::BarChart;
1428
1429        let chart = BarChart::new([10., 20.])
1430            .band(|v| format!("{v}"))
1431            .value(|v| *v)
1432            .value_tick_count(3);
1433        assert_eq!(chart.value_tick_labels(), vec!["20", "10", "0"]);
1434
1435        let money = chart.value_tick_format(|v| format!("${v:.0}"));
1436        assert_eq!(money.value_tick_labels(), vec!["$20", "$10", "$0"]);
1437
1438        // Labels spread over every band, so they stay put as the data grows.
1439        assert_eq!(money.band_count(12).band_slots(), 12);
1440    }
1441
1442    #[test]
1443    fn a_band_count_keeps_each_bar_in_its_band() {
1444        use gpui_kit::{Bounds, point, px, size};
1445
1446        use super::BarChart;
1447        use crate::plot::{AxisLabelPlacement, scale::Scale};
1448
1449        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(40.), px(100.)));
1450        let chart = |data: &[f64], count| {
1451            BarChart::new(data.to_vec())
1452                .band(|v| format!("{v}"))
1453                .value(|v| *v)
1454                .band_count(count)
1455        };
1456
1457        // Two bars laid out for four bands take the first half of the width.
1458        let wide = chart(&[1., 2.], 2).band_scale(bounds).unwrap();
1459        let narrow = chart(&[1., 2.], 4).band_scale(bounds).unwrap();
1460        assert_eq!(narrow.band_width() * 2., wide.band_width());
1461        assert!(narrow.tick(&"2".to_string()).unwrap() < 20.);
1462
1463        // A bar keeps its place as the data grows into the empty bands.
1464        let grown = chart(&[1., 2., 3.], 4).band_scale(bounds).unwrap();
1465        assert_eq!(grown.tick(&"2".to_string()), narrow.tick(&"2".to_string()));
1466        assert_eq!(grown.band_width(), narrow.band_width());
1467
1468        // Labels inside the plot leave the bars their full width.
1469        let outside = chart(&[1., 2.], 2).value_axis(true);
1470        let inside = chart(&[1., 2.], 2)
1471            .value_axis(true)
1472            .value_axis_label_placement(AxisLabelPlacement::Inside);
1473        assert_eq!(outside.value_axis_gap(), super::VALUE_AXIS_GAP);
1474        assert_eq!(inside.value_axis_gap(), 0.);
1475    }
1476
1477    /// A tooltip row shows its bar's color: a solid fill as is, a
1478    /// `fill_gradient` by its first stop, and the default fill otherwise.
1479    #[gpui_kit::test]
1480    fn the_tooltip_swatch_follows_the_bar_color(cx: &mut gpui_kit::TestAppContext) {
1481        cx.update(crate::init);
1482        let bars = || {
1483            BarChart::new([1., -2.])
1484                .band(|d: &f64| SharedString::from(format!("{d}")))
1485                .value(|d: &f64| *d)
1486        };
1487        let frame = Bounds::default();
1488        let bounds = Bounds::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)));
1489        let (default, solid, gradient, stops) = cx.update(|cx| {
1490            let gain = gpui_kit::green();
1491            let loss = gpui_kit::red();
1492            let default = bars().bar_color(&1., frame, bounds, cx);
1493            let solid = bars()
1494                .fill(move |d: &f64, _, _, _| if *d >= 0. { gain } else { loss })
1495                .bar_color(&-2., frame, bounds, cx);
1496            let gradient = bars()
1497                .fill(move |_: &f64, _, _, _| {
1498                    linear_gradient(
1499                        0.,
1500                        gpui_kit::linear_color_stop(gain, 0.),
1501                        gpui_kit::linear_color_stop(loss, 1.),
1502                    )
1503                })
1504                .bar_color(&1., frame, bounds, cx);
1505            let stops = bars()
1506                .fill_gradient(move |_: &f64, _, _| {
1507                    [
1508                        gpui_kit::linear_color_stop(gain, 0.),
1509                        gpui_kit::linear_color_stop(loss, 1.),
1510                    ]
1511                })
1512                .bar_color(&1., frame, bounds, cx);
1513            (default, solid, gradient, stops)
1514        });
1515        let accent = cx.update(|cx| cx.macos().chart[0]);
1516
1517        assert_eq!(default, accent);
1518        assert_eq!(solid, gpui_kit::red());
1519        assert_eq!(gradient, accent);
1520        assert_eq!(stops, gpui_kit::green());
1521    }
1522
1523    /// The tooltip reads each bar's frame as `paint` lays it out, so a `fill`
1524    /// that reads the frame colors the swatch as it colors the bar.
1525    #[test]
1526    fn the_tooltip_reads_the_painted_bar_frame() {
1527        let bars = BarChart::new([1., -2.])
1528            .band(|d: &f64| SharedString::from(format!("{d}")))
1529            .value(|d: &f64| *d);
1530        let bounds = Bounds::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)));
1531        let band_scale = bars.band_scale(bounds).expect("bars have a band scale");
1532        let up = bars.bar_frame(&1., &band_scale, bounds).expect("a frame");
1533        let down = bars.bar_frame(&-2., &band_scale, bounds).expect("a frame");
1534
1535        // Both grow from zero: one up, one down twice as far.
1536        assert_eq!(up.origin.y + up.size.height, down.origin.y);
1537        assert!((down.size.height - 2. * up.size.height).abs() < 0.01);
1538        assert!(up.origin.x < down.origin.x);
1539        assert_eq!(up.size.width, band_scale.band_width());
1540    }
1541}