Skip to main content

gpui_component/chart/
bar_chart.rs

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