Skip to main content

gpui_component/chart/
bar_chart.rs

1use std::{hash::Hash, ops::RangeInclusive, rc::Rc};
2
3use gpui::{
4    AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop,
5    Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::{Num, ToPrimitive};
10
11use crate::{
12    ActiveTheme,
13    plot::{
14        AXIS_GAP, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel,
15        label::{TEXT_GAP, TEXT_SIZE, Text, measure_text_width},
16        scale::{Scale, ScaleBand, ScaleLinear, Sealed},
17        shape::{Bar, BarAlignment},
18        tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
19    },
20};
21
22use super::{build_band_labels, pointer_spring};
23
24/// Space reserved along the band axis for the value-axis tick labels, in pixels.
25///
26/// Like [`AXIS_GAP`] this is a fixed budget rather than a measured one: the band
27/// scale is also rebuilt during hit-testing, where no [`Window`] is available to
28/// shape text. Values wider than this (very large numbers) will overflow it.
29const VALUE_AXIS_GAP: f32 = 32.;
30
31/// How much the bars away from the hovered one fade, as a share of their opacity.
32const HOVER_DIM: f32 = 0.45;
33
34/// The hover a bar chart paints, sampled once per frame in [`Plot::hover`].
35#[derive(Clone, Copy)]
36struct BarHover {
37    /// Cross-axis center of the highlight band, springing between bars.
38    center: f32,
39    /// How far the hover has faded in.
40    focus: f32,
41}
42
43#[derive(IntoPlot)]
44pub struct BarChart<T, B, V>
45where
46    T: 'static,
47    B: Eq + Hash + Into<SharedString> + 'static,
48    V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
49{
50    data: Vec<T>,
51    band: Option<Rc<dyn Fn(&T) -> B>>,
52    value: Option<Rc<dyn Fn(&T) -> V>>,
53    fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
54    #[allow(clippy::type_complexity)]
55    fill_gradient:
56        Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
57    tick_margin: usize,
58    label: Option<Rc<dyn Fn(&T) -> SharedString>>,
59    label_axis: bool,
60    value_axis: bool,
61    value_tick_count: usize,
62    grid: bool,
63    alignment: BarAlignment,
64    corner_radii: Corners<Pixels>,
65    id: Option<ElementId>,
66    name: Option<SharedString>,
67    /// The label gaps of horizontal bars, measured in `prepaint` for the frame,
68    /// so `tooltip_state` (which has no window) can keep the hover off the labels.
69    horizontal_gaps: (f32, f32),
70    hover: Option<BarHover>,
71}
72
73impl<T, B, V> BarChart<T, B, V>
74where
75    B: Eq + Hash + Into<SharedString> + 'static,
76    V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
77{
78    pub fn new<I>(data: I) -> Self
79    where
80        I: IntoIterator<Item = T>,
81    {
82        Self {
83            data: data.into_iter().collect(),
84            band: None,
85            value: None,
86            fill: None,
87            fill_gradient: None,
88            tick_margin: 1,
89            label: None,
90            label_axis: true,
91            value_axis: false,
92            value_tick_count: 4,
93            grid: true,
94            alignment: BarAlignment::default(),
95            corner_radii: Corners::all(px(0.)),
96            id: None,
97            name: None,
98            horizontal_gaps: (0., 0.),
99            hover: None,
100        }
101    }
102
103    /// Enable an interactive hover tooltip (crosshair + category/value) for this chart.
104    ///
105    /// The `id` must be unique among sibling elements. Without it, the chart stays a
106    /// non-interactive plot. Works for every [`BarAlignment`] (vertical bars get a
107    /// vertical crosshair, horizontal bars a horizontal one).
108    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
109        self.id = Some(id.into());
110        self
111    }
112
113    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
114    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
115        self.name = Some(name.into());
116        self
117    }
118
119    /// Map each datum to its band-axis value (the categorical/ordinal axis).
120    pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
121        self.band = Some(Rc::new(band));
122        self
123    }
124
125    /// Map each datum to its numeric value along the value axis.
126    pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
127        self.value = Some(Rc::new(value));
128        self
129    }
130
131    /// Set a per-datum verbatim fill.
132    ///
133    /// The closure receives:
134    ///
135    /// 1. the datum,
136    /// 2. the **bar's bounds** in pixel space, expressed relative to the
137    ///    chart's origin (i.e. the bar's painted rectangle within the chart),
138    /// 3. the **chart's bounds** in pixel space with origin `(0, 0)` and size
139    ///    equal to the full chart extent, and
140    /// 4. the bar's [`BarAlignment`] (so callers can branch on orientation,
141    ///    e.g. flip a gradient angle).
142    ///
143    /// Both rectangles share the same coordinate system, so callers can
144    /// implement arbitrary chart-aware backgrounds — bar-local gradients,
145    /// chart-wide gradients, patterns, sampled colormaps, etc. — without any
146    /// help from the library.
147    ///
148    /// Accepts any type convertible to [`Background`]. Setting this clears any
149    /// previously set [`BarChart::fill_gradient`].
150    pub fn fill<Bg>(
151        mut self,
152        fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
153    ) -> Self
154    where
155        Bg: Into<Background> + 'static,
156    {
157        self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
158            fill(t, bar_bounds, chart_bounds, alignment).into()
159        }));
160        self.fill_gradient = None;
161        self
162    }
163
164    /// Set a per-datum auto-oriented linear gradient fill.
165    ///
166    /// The closure receives the datum, the chart's full data range
167    /// (`chart_range`, derived from all data values), and a `chart_to_bar`
168    /// remap helper that maps a chart-value coordinate to a bar-local
169    /// gradient position (where `0.0` is the bar's base and `1.0` is its tip).
170    ///
171    /// Use bar-local positions directly for per-bar gradients (every bar
172    /// looks the same regardless of its value):
173    ///
174    /// ```ignore
175    /// .fill_gradient(|_, _, _| [
176    ///     linear_color_stop(c.opacity(0.3), 0.0),
177    ///     linear_color_stop(c, 1.0),
178    /// ])
179    /// ```
180    ///
181    /// Or use `chart_to_bar` to position stops at chart-relative values, so
182    /// each bar shows the slice of a chart-wide gradient corresponding to
183    /// its own `[base, value]` span:
184    ///
185    /// ```ignore
186    /// .fill_gradient(|_, chart_range, chart_to_bar| [
187    ///     linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())),
188    ///     linear_color_stop(c,              chart_to_bar(*chart_range.end())),
189    /// ])
190    /// ```
191    ///
192    /// Stop positions returned outside `[0, 1]` are clipped to the bar; the
193    /// library interpolates colors at the clip points so the on-bar gradient
194    /// still matches the chart-wide one.
195    ///
196    /// The gradient angle is derived from [`BarAlignment`] so stop-0 is at the
197    /// base and stop-1 at the tip. Setting this clears any previously set
198    /// [`BarChart::fill`].
199    pub fn fill_gradient(
200        mut self,
201        fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
202    ) -> Self {
203        self.fill_gradient = Some(Rc::new(fill));
204        self.fill = None;
205        self
206    }
207
208    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
209        self.tick_margin = tick_margin;
210        self
211    }
212
213    pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
214    where
215        S: Into<SharedString> + 'static,
216    {
217        self.label = Some(Rc::new(move |t| label(t).into()));
218        self
219    }
220
221    /// Show or hide the band-axis line and labels.
222    ///
223    /// Default is true.
224    pub fn label_axis(mut self, label_axis: bool) -> Self {
225        self.label_axis = label_axis;
226        self
227    }
228
229    /// Show or hide the value-axis tick labels.
230    ///
231    /// Enabling this reserves [`VALUE_AXIS_GAP`] along the band axis (left of
232    /// vertical bars, below horizontal ones) for the labels.
233    ///
234    /// Default is false.
235    pub fn value_axis(mut self, value_axis: bool) -> Self {
236        self.value_axis = value_axis;
237        self
238    }
239
240    /// Set how many even intervals the value axis is divided into, which drives
241    /// both the grid line spacing and the value-axis tick labels.
242    ///
243    /// This is a count, unlike [`Self::tick_margin`], which is a stride over the
244    /// band axis categories.
245    ///
246    /// Default is 4.
247    pub fn value_tick_count(mut self, value_tick_count: usize) -> Self {
248        self.value_tick_count = value_tick_count.max(1);
249        self
250    }
251
252    pub fn grid(mut self, grid: bool) -> Self {
253        self.grid = grid;
254        self
255    }
256
257    /// Set the bar alignment.
258    ///
259    /// Default is [`BarAlignment::Bottom`].
260    pub fn alignment(mut self, alignment: BarAlignment) -> Self {
261        self.alignment = alignment;
262        self
263    }
264
265    /// Set the corner radii applied to every bar rectangle.
266    ///
267    /// Use [`Corners::all`] for uniform rounding, or construct [`Corners`] manually
268    /// to round only specific corners (e.g. just the tip end of each bar).
269    pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
270        self.corner_radii = corner_radii.into();
271        self
272    }
273
274    /// The band scale (matching `paint`): spans the height for horizontal bars, the width
275    /// otherwise. Shared by `tooltip_state` and `tooltip`.
276    fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
277        let band_fn = self.band.as_ref()?;
278        let band_extent = if self.alignment.is_horizontal() {
279            bounds.size.height.as_f32()
280        } else {
281            bounds.size.width.as_f32()
282        };
283        // Value-axis labels eat into the band extent at one end; `band_offset`
284        // shifts the bands away from that end when it is the leading one.
285        let gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
286        Some(
287            ScaleBand::new(
288                self.data.iter().map(|v| band_fn(v)).collect(),
289                vec![0., (band_extent - gap).max(0.)],
290            )
291            .padding_inner(0.4)
292            .padding_outer(0.2),
293        )
294    }
295
296    /// Offset added to every band-scale tick.
297    ///
298    /// [`ScaleBand`] ignores the start of its range, so vertical bars are shifted
299    /// by hand to clear the value-axis labels on their left. Horizontal bars put
300    /// those labels below the plot, past the end of the band axis, so they need no
301    /// shift.
302    fn band_offset(&self) -> f32 {
303        if self.value_axis && !self.alignment.is_horizontal() {
304            VALUE_AXIS_GAP
305        } else {
306            0.
307        }
308    }
309
310    /// Label gaps `(band_side, value_end_side)` reserved along the value axis for
311    /// horizontal bars, measured from the actual label text. Measured once per frame
312    /// in `prepaint` and kept in `horizontal_gaps`, so `paint` and the tooltip share
313    /// one measurement and the crosshair lines up with the bar region.
314    fn measure_horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
315        let Some(band_fn) = self.band.as_ref() else {
316            return (0., 0.);
317        };
318        let font_size = px(TEXT_SIZE);
319        let band_gap = if self.label_axis {
320            self.data
321                .iter()
322                .map(|v| {
323                    let s: SharedString = band_fn(v).into();
324                    measure_text_width(&s, font_size, window)
325                })
326                .fold(0f32, f32::max)
327                + TEXT_GAP * 2.
328        } else {
329            0.
330        };
331        let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
332            self.data
333                .iter()
334                .map(|v| measure_text_width(&label_fn(v), font_size, window))
335                .fold(0f32, f32::max)
336                + TEXT_GAP * 2.
337        } else {
338            TEXT_GAP * 4.
339        };
340        (band_gap, value_end_gap)
341    }
342
343    /// The extent `(start, length)` of the bars along the value axis, which the
344    /// hover is confined to so the axis labels never show a tooltip.
345    fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
346        if self.alignment.is_horizontal() {
347            let (band_gap, value_end_gap) = self.horizontal_gaps;
348            let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
349            let start = if matches!(self.alignment, BarAlignment::Left) {
350                band_gap
351            } else {
352                value_end_gap
353            };
354            (start, length)
355        } else {
356            let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
357            let length = bounds.size.height.as_f32() - axis_gap;
358            let start = if matches!(self.alignment, BarAlignment::Top) {
359                axis_gap
360            } else {
361                0.
362            };
363            (start, length)
364        }
365    }
366
367    /// Whether the cursor is over a bar's row or column rather than the axis labels.
368    fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
369        let (start, length) = self.value_extent(bounds);
370        if self.alignment.is_horizontal() {
371            let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
372            (start..=start + length).contains(&position.x.as_f32())
373                && !(self.value_axis && position.y.as_f32() > value_labels_top)
374        } else {
375            (start..=start + length).contains(&position.y.as_f32())
376                && position.x.as_f32() >= self.band_offset()
377        }
378    }
379}
380
381impl<T, B, V> Plot for BarChart<T, B, V>
382where
383    B: Eq + Hash + Into<SharedString> + 'static,
384    V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
385{
386    fn prepaint(
387        &mut self,
388        _bounds: Bounds<Pixels>,
389        window: &mut Window,
390        _cx: &mut App,
391    ) -> Vec<AnyElement> {
392        self.horizontal_gaps = if self.alignment.is_horizontal() {
393            self.measure_horizontal_gaps(window)
394        } else {
395            (0., 0.)
396        };
397        vec![]
398    }
399
400    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
401        let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
402            return;
403        };
404
405        let total_width = bounds.size.width.as_f32();
406        let total_height = bounds.size.height.as_f32();
407        let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
408        let alignment = self.alignment;
409        let is_horizontal = alignment.is_horizontal();
410
411        // Band scale spans the full extent perpendicular to the value axis. Shared with the
412        // tooltip via `band_scale()` so the bars and the hover crosshair stay aligned.
413        let Some(band_scale) = self.band_scale(bounds) else {
414            return;
415        };
416        let band_width = band_scale.band_width();
417
418        let value_dim = if is_horizontal {
419            total_width
420        } else {
421            total_height
422        };
423        // For horizontal charts the band labels (category names) are rendered
424        // along the value axis and can be arbitrarily wide, so we measure the
425        // actual maximum label width instead of using a fixed constant.
426        // Similarly, value labels (numbers) at the bar ends are measured so the
427        // scale range is always shrunk by exactly the right amount.
428        let (band_gap, value_end_gap) = if is_horizontal {
429            self.horizontal_gaps
430        } else {
431            (axis_gap, 10.)
432        };
433        let (range, baseline) = match alignment {
434            BarAlignment::Bottom => {
435                let baseline = value_dim - axis_gap;
436                (vec![baseline, 10.], baseline)
437            }
438            BarAlignment::Top => {
439                let baseline = axis_gap;
440                (vec![baseline, value_dim - 10.], baseline)
441            }
442            BarAlignment::Left => {
443                let baseline = band_gap;
444                (vec![baseline, value_dim - value_end_gap], baseline)
445            }
446            BarAlignment::Right => {
447                let baseline = value_dim - band_gap;
448                (vec![baseline, value_end_gap], baseline)
449            }
450        };
451        let value_scale = ScaleLinear::new(
452            self.data
453                .iter()
454                .map(|v| value_fn(v))
455                .chain(Some(V::zero()))
456                .collect(),
457            range,
458        );
459
460        // Where zero sits along the value axis. Bars grow from here rather than from
461        // the geometric baseline, so negative values extend to the opposite side. With
462        // no negative data zero is the domain minimum and this is the baseline.
463        let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
464        let band_offset = self.band_offset();
465
466        // Grid lines and the zero line span their bounds edge to edge, so they are
467        // painted into bounds inset by the value-axis gap. Without this they run
468        // straight through the value-axis labels.
469        let value_axis_gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
470        let plot_bounds = if is_horizontal {
471            Bounds {
472                origin: bounds.origin,
473                size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
474            }
475        } else {
476            Bounds {
477                origin: bounds.origin + point(px(value_axis_gap), px(0.)),
478                size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
479            }
480        };
481
482        // Value domain, matching `value_scale`'s (which is the data plus zero).
483        // `far` maps to the maximum and `baseline` to the minimum.
484        let (domain_lo, domain_hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
485            let f = value_fn(v).to_f32().unwrap_or(0.);
486            (lo.min(f), hi.max(f))
487        });
488
489        // Draw band axis (with categorical labels).
490        let mut axis = PlotAxis::new().stroke(cx.theme().border);
491        if self.label_axis {
492            match alignment {
493                BarAlignment::Bottom | BarAlignment::Top => {
494                    axis = axis.x(zero_pixel);
495
496                    // Labels are placed one at a time rather than through
497                    // `x_label`, because a chart with negative values needs them
498                    // on either side of the zero line: each label goes on the side
499                    // its own bar leaves empty.
500                    let labels = self
501                        .data
502                        .iter()
503                        .enumerate()
504                        .filter(|(i, _)| (i + 1) % self.tick_margin == 0)
505                        .filter_map(|(_, d)| {
506                            let band_x = band_scale.tick(&band_fn(d))?;
507                            let value = value_fn(d).to_f32().unwrap_or(0.);
508                            let label_y = if label_below_zero_line(value, alignment) {
509                                zero_pixel + TEXT_GAP
510                            } else {
511                                zero_pixel - TEXT_GAP - TEXT_SIZE
512                            };
513
514                            Some(
515                                Text::new(
516                                    band_fn(d).into(),
517                                    point(px(band_x + band_offset + band_width / 2.), px(label_y)),
518                                    cx.theme().muted_foreground,
519                                )
520                                .align(TextAlign::Center),
521                            )
522                        })
523                        .collect();
524                    PlotLabel::new(labels).paint(&bounds, window, cx);
525                }
526                BarAlignment::Left | BarAlignment::Right => {
527                    let labels = build_band_labels(
528                        &self.data,
529                        band_fn.as_ref(),
530                        &band_scale,
531                        band_width,
532                        self.tick_margin,
533                        cx.theme().muted_foreground,
534                    );
535                    let (side, align) = if matches!(alignment, BarAlignment::Left) {
536                        (AxisLabelSide::Start, TextAlign::Right)
537                    } else {
538                        (AxisLabelSide::End, TextAlign::Left)
539                    };
540                    axis = axis
541                        .y(zero_pixel)
542                        .y_label_side(side)
543                        .y_label(labels.into_iter().map(|t| t.align(align)));
544                }
545            }
546        }
547        axis.paint(&plot_bounds, window, cx);
548
549        // Far edge of the value axis in pixel space (opposite the baseline).
550        let far = match alignment {
551            BarAlignment::Bottom => 10.,
552            BarAlignment::Top => value_dim - 10.,
553            BarAlignment::Left => value_dim - value_end_gap,
554            BarAlignment::Right => value_end_gap,
555        };
556
557        let steps = self.value_tick_count;
558        let value_ticks = value_tick_positions(far, baseline, steps);
559
560        // Draw grid, excluding the line at the baseline.
561        if self.grid {
562            let grid = Grid::new()
563                .stroke(cx.theme().border)
564                .dash_array(&[px(4.), px(2.)]);
565            let lines = value_ticks[..steps].to_vec();
566            let grid = if is_horizontal {
567                grid.x(lines)
568            } else {
569                grid.y(lines)
570            };
571            grid.paint(&plot_bounds, window);
572        }
573
574        if self.value_axis {
575            // Ticks run from `far` (the domain maximum) to `baseline` (the minimum),
576            // so the labels walk the domain in the same direction.
577            let labels = value_ticks.iter().enumerate().map(|(i, &tick)| {
578                let value = domain_hi - (domain_hi - domain_lo) * i as f32 / steps as f32;
579                AxisText::new(format_tick(value), px(tick), cx.theme().muted_foreground)
580            });
581
582            // The labels go in the gap `band_scale` kept clear for them, right-aligned
583            // against the plot area for vertical bars and centred under it otherwise.
584            let value_axis = if is_horizontal {
585                PlotAxis::new()
586                    .x_axis(false)
587                    .x(px(total_height - VALUE_AXIS_GAP))
588                    .x_label(labels.map(|t| t.align(TextAlign::Center)))
589            } else {
590                PlotAxis::new()
591                    .y_axis(false)
592                    .y(px(VALUE_AXIS_GAP - TEXT_GAP * 2.))
593                    .y_label(labels.map(|t| t.align(TextAlign::Right)))
594            };
595            value_axis.paint(&bounds, window, cx);
596        }
597
598        // Draw bars.
599        let band_fn_cloned = band_fn.clone();
600        let value_fn_cloned = value_fn.clone();
601        let default_fill: Background = cx.theme().chart_2.into();
602        let fill = self.fill.clone();
603        let fill_gradient = self.fill_gradient.clone();
604        let label_color = cx.theme().foreground;
605
606        // Chart bounds in pixel space, with origin (0, 0) and size equal to
607        // the full chart extent. Passed to user `fill` closures so they can
608        // position chart-wide backgrounds (gradients, patterns, etc.).
609        let chart_bounds: Bounds<f32> = Bounds {
610            origin: Point::new(0., 0.),
611            size: Size::new(total_width, total_height),
612        };
613
614        // Chart data range in f32 — passed to `fill_gradient` callers and used
615        // by the `chart_to_bar` remap helper.
616        let chart_range = {
617            let mut lo = 0.0_f32;
618            let mut hi = 0.0_f32;
619            for v in &self.data {
620                if let Some(f) = value_fn(v).to_f32() {
621                    lo = lo.min(f);
622                    hi = hi.max(f);
623                }
624            }
625            lo..=hi
626        };
627
628        // The hovered bar keeps its color while the others fade behind it. The
629        // highlight band springs between bars, so each bar's emphasis follows the
630        // band's distance from it and the focus hands over as the band slides.
631        let hover = self.hover;
632        let step = band_scale.step().max(f32::EPSILON);
633        let emphasis = move |frame: Bounds<f32>| -> f32 {
634            let Some(hover) = hover else {
635                return 1.;
636            };
637            let center = if is_horizontal {
638                frame.origin.y + frame.size.height / 2.
639            } else {
640                frame.origin.x + frame.size.width / 2.
641            };
642            let distance = ((center - hover.center).abs() / step).min(1.);
643            1. - HOVER_DIM * hover.focus * distance
644        };
645
646        let mut bar = Bar::new()
647            .data(&self.data)
648            .alignment(alignment)
649            .band_width(band_width)
650            .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
651            .base(move |_| zero_pixel)
652            .value(move |d| value_scale.tick(&value_fn_cloned(d)))
653            .corner_radii(self.corner_radii);
654
655        bar = match (fill, fill_gradient) {
656            (_, Some(fg)) => {
657                let value_fn_for_grad = value_fn.clone();
658                bar.fill(move |d, frame, alignment| {
659                    let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
660                    let base_v = 0.0_f32;
661                    let bar_lo = base_v.min(v);
662                    let bar_hi = base_v.max(v);
663                    let bar_span = (bar_hi - bar_lo).max(f32::EPSILON);
664                    let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
665                    let stops = fg(d, chart_range.clone(), &chart_to_bar);
666                    let [s0, s1] = clip_stops_to_bar(stops);
667                    let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
668                    bg.opacity(emphasis(frame))
669                })
670            }
671            (Some(f), _) => bar.fill(move |d, frame, alignment| {
672                f(d, frame, chart_bounds, alignment).opacity(emphasis(frame))
673            }),
674            _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))),
675        };
676
677        if let Some(label) = self.label.as_ref() {
678            let label = label.clone();
679            let text_align = match alignment {
680                BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
681                BarAlignment::Left => TextAlign::Left,
682                BarAlignment::Right => TextAlign::Right,
683            };
684            bar =
685                bar.label(move |d, p| vec![Text::new(label(d), p, label_color).align(text_align)]);
686        }
687
688        bar.paint(&bounds, window, cx);
689    }
690
691    fn id(&self) -> Option<ElementId> {
692        self.id.clone()
693    }
694
695    fn tooltip_state(
696        &self,
697        position: Point<Pixels>,
698        bounds: Bounds<Pixels>,
699        _cx: &App,
700    ) -> Option<TooltipState> {
701        let band_fn = self.band.as_ref()?;
702        self.value.as_ref()?;
703
704        // Skip the tooltip when the cursor is over the axis labels, not a bar.
705        if !self.is_over_bars(position, bounds) {
706            return None;
707        }
708
709        // Only the band scale is needed to hit-test which bar is hovered; the label
710        // gaps were measured in `prepaint`, so no `window` is required here.
711        let is_horizontal = self.alignment.is_horizontal();
712        let band_scale = self.band_scale(bounds)?;
713        let band_width = band_scale.band_width();
714
715        let band_offset = self.band_offset();
716        let cursor_band = if is_horizontal {
717            position.y
718        } else {
719            position.x
720        };
721        let index = band_scale.least_index(cursor_band.as_f32() - band_offset);
722        let d = self.data.get(index)?;
723        let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
724
725        // Vertical bars: vertical crosshair at the bar's x. Horizontal bars: horizontal
726        // crosshair at the bar's y. The box tracks the cursor either way.
727        let cross_line = if is_horizontal {
728            point(position.x, px(center))
729        } else {
730            point(px(center), position.y)
731        };
732
733        Some(TooltipState::new(index, cross_line, vec![]))
734    }
735
736    fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
737        self.hover = hover.map(|hover| {
738            // The band slides to the hovered bar; on the first hovered frame it
739            // adopts the bar instead of travelling from where the last hover ended.
740            let target = if self.alignment.is_horizontal() {
741                hover.state().cross_line.y
742            } else {
743                hover.state().cross_line.x
744            };
745            let center = spring(
746                ("bar-chart", "band"),
747                target,
748                pointer_spring(cx).with_travel(!hover.is_entering()),
749                window,
750                cx,
751            );
752            BarHover {
753                center: center.as_f32(),
754                focus: hover.focus(),
755            }
756        });
757    }
758
759    fn tooltip(
760        &self,
761        state: &TooltipState,
762        cursor: Point<Pixels>,
763        bounds: Bounds<Pixels>,
764        _window: &mut Window,
765        cx: &mut App,
766    ) -> Option<AnyElement> {
767        let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
768        let d = self.data.get(state.index)?;
769        let title: SharedString = band_fn(d).into();
770        let value = value_fn(d).to_f64()?;
771        let name = self.name.clone().unwrap_or_default();
772
773        // Highlight the hovered bar with a translucent band the width of the bar, instead
774        // of a hairline. Confined to the plot area so it doesn't cover the axis labels,
775        // and centered where the band spring has reached rather than snapped to the bar.
776        let band_width = self.band_scale(bounds)?.band_width();
777        let center = self.hover.map_or(state.cross_line, |hover| {
778            if self.alignment.is_horizontal() {
779                point(state.cross_line.x, px(hover.center))
780            } else {
781                point(px(hover.center), state.cross_line.y)
782            }
783        });
784        let (start, length) = self.value_extent(bounds);
785        let cross_line = if self.alignment.is_horizontal() {
786            CrossLine::new(center)
787                .horizontal()
788                .h_span(start, length)
789                .band(px(band_width))
790        } else {
791            CrossLine::new(center)
792                .span(start, length)
793                .band(px(band_width))
794        };
795
796        Some(
797            // Follow the cursor; the highlight band stays snapped to the bar.
798            Tooltip::new(cursor, bounds.size)
799                .gap(px(8.))
800                .cross_line(cross_line)
801                .title(title)
802                .row(cx.theme().chart_2, name, format!("{}", value))
803                .into_any_element(),
804        )
805    }
806}
807
808/// Clip a two-stop gradient to bar-local `[0, 1]`, interpolating colors at the
809/// clip points so the on-bar gradient matches the (possibly broader) gradient
810/// the caller defined.
811///
812/// When a stop position falls outside `[0, 1]` (e.g. because `chart_to_bar`
813/// returned a value past the bar's edge for a chart-relative gradient),
814/// gpui's renderer would clamp the position and lose the gradient effect.
815/// This function instead replaces such a stop with the color sampled along
816/// the line through both stops at position `0.0` or `1.0`, preserving the
817/// visual slice.
818fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
819    let [a, b] = stops;
820    let p0 = a.percentage;
821    let p1 = b.percentage;
822    let lerp = |t: f32| -> Hsla {
823        Hsla {
824            h: a.color.h + (b.color.h - a.color.h) * t,
825            s: a.color.s + (b.color.s - a.color.s) * t,
826            l: a.color.l + (b.color.l - a.color.l) * t,
827            a: a.color.a + (b.color.a - a.color.a) * t,
828        }
829    };
830    let span = p1 - p0;
831    let sample = |target: f32| -> Hsla {
832        if span.abs() < f32::EPSILON {
833            a.color
834        } else {
835            lerp((target - p0) / span)
836        }
837    };
838    let new_a = if (0. ..=1.).contains(&p0) {
839        a
840    } else {
841        LinearColorStop {
842            color: sample(p0.clamp(0., 1.)),
843            percentage: p0.clamp(0., 1.),
844        }
845    };
846    let new_b = if (0. ..=1.).contains(&p1) {
847        b
848    } else {
849        LinearColorStop {
850            color: sample(p1.clamp(0., 1.)),
851            percentage: p1.clamp(0., 1.),
852        }
853    };
854    [new_a, new_b]
855}
856
857/// Format a tick value for display on the value axis.
858fn format_tick(v: f32) -> String {
859    if (v - v.round()).abs() < 0.001 {
860        format!("{:.0}", v)
861    } else {
862        format!("{:.1}", v)
863    }
864}
865
866/// Whether a vertical bar's category label belongs below the zero line.
867///
868/// A bar grows away from the zero line, so its label goes on the side the bar
869/// leaves empty. Which side that is flips with both the sign of the value and the
870/// alignment. A zero-length bar counts as positive, which puts its label in the
871/// axis gap rather than inside the plot.
872fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
873    (value < 0.) == (alignment == BarAlignment::Top)
874}
875
876/// Tick positions along the value axis, dividing it into `steps` even intervals.
877///
878/// Runs from `far` (the value domain's maximum) through `baseline` (its minimum)
879/// inclusive, so the result holds `steps + 1` positions and the last one is the
880/// baseline.
881fn value_tick_positions(far: f32, baseline: f32, steps: usize) -> Vec<f32> {
882    (0..=steps)
883        .map(|i| far + (baseline - far) * i as f32 / steps as f32)
884        .collect()
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn test_label_below_zero_line() {
893        // Bottom-aligned: positive bars grow up, leaving the space below free.
894        assert!(label_below_zero_line(5., BarAlignment::Bottom));
895        assert!(label_below_zero_line(0., BarAlignment::Bottom));
896        assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
897
898        // Top-aligned bars grow the other way, so the sides swap.
899        assert!(!label_below_zero_line(5., BarAlignment::Top));
900        assert!(!label_below_zero_line(0., BarAlignment::Top));
901        assert!(label_below_zero_line(-5., BarAlignment::Top));
902    }
903
904    #[test]
905    fn test_value_tick_positions() {
906        // Both ends are included, so 4 intervals means 5 positions.
907        assert_eq!(
908            value_tick_positions(10., 110., 4),
909            vec![10., 35., 60., 85., 110.]
910        );
911
912        // Top-aligned charts have the baseline before the far edge.
913        assert_eq!(value_tick_positions(110., 10., 2), vec![110., 60., 10.]);
914
915        assert_eq!(value_tick_positions(0., 50., 1), vec![0., 50.]);
916    }
917}