Skip to main content

rgpui_component/plot/shape/
bar.rs

1use rgpui::{
2    App, Background, Bounds, Corners, PaintQuad, Pixels, Point, Size, Window, fill, point, px,
3};
4
5use crate::plot::{
6    label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text},
7    origin_point,
8};
9
10/// Alignment of bars within a [`Bar`] shape, controlling both the orientation
11/// (vertical vs horizontal) and the side where the baseline lives.
12#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
13pub enum BarAlignment {
14    /// Vertical bars with the baseline at the bottom; bars grow upward.
15    #[default]
16    Bottom,
17    /// Vertical bars with the baseline at the top; bars grow downward.
18    Top,
19    /// Horizontal bars with the baseline at the left; bars grow rightward.
20    Left,
21    /// Horizontal bars with the baseline at the right; bars grow leftward.
22    Right,
23}
24
25impl BarAlignment {
26    pub fn is_horizontal(self) -> bool {
27        matches!(self, Self::Left | Self::Right)
28    }
29
30    pub fn is_vertical(self) -> bool {
31        !self.is_horizontal()
32    }
33
34    /// Linear-gradient angle (in degrees) that runs from the bar's base to its
35    /// tip for this alignment.
36    ///
37    /// gpui convention: `0°` points upward (stop-0 at bottom, stop-1 at top);
38    /// angles increase clockwise.
39    pub fn gradient_angle(self) -> f32 {
40        match self {
41            Self::Bottom => 0.,
42            Self::Top => 180.,
43            Self::Left => 90.,
44            Self::Right => 270.,
45        }
46    }
47}
48
49#[allow(clippy::type_complexity)]
50pub struct Bar<T> {
51    data: Vec<T>,
52    alignment: BarAlignment,
53    cross: Box<dyn Fn(&T) -> Option<f32>>,
54    band_width: f32,
55    base: Box<dyn Fn(&T) -> f32>,
56    value: Box<dyn Fn(&T) -> Option<f32>>,
57    fill: Box<dyn Fn(&T, Bounds<f32>, BarAlignment) -> Background>,
58    label: Option<Box<dyn Fn(&T, Point<Pixels>) -> Vec<Text>>>,
59    corner_radii: Corners<Pixels>,
60}
61
62impl<T> Default for Bar<T> {
63    fn default() -> Self {
64        Self {
65            data: Vec::new(),
66            alignment: BarAlignment::default(),
67            cross: Box::new(|_| None),
68            band_width: 0.,
69            base: Box::new(|_| 0.),
70            value: Box::new(|_| None),
71            fill: Box::new(|_, _, _| rgpui::black().into()),
72            label: None,
73            corner_radii: Corners::all(px(0.)),
74        }
75    }
76}
77
78impl<T> Bar<T> {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Set the data of the Bar.
84    pub fn data<I>(mut self, data: I) -> Self
85    where
86        I: IntoIterator<Item = T>,
87    {
88        self.data = data.into_iter().collect();
89        self
90    }
91
92    /// Set the alignment of the Bar.
93    ///
94    /// Default is [`BarAlignment::Bottom`].
95    pub fn alignment(mut self, alignment: BarAlignment) -> Self {
96        self.alignment = alignment;
97        self
98    }
99
100    /// Set the cross-axis position of each bar (in pixels).
101    ///
102    /// For vertical alignments this is the X coordinate; for horizontal
103    /// alignments this is the Y coordinate.
104    pub fn cross<F>(mut self, cross: F) -> Self
105    where
106        F: Fn(&T) -> Option<f32> + 'static,
107    {
108        self.cross = Box::new(cross);
109        self
110    }
111
112    /// Set the band width of the Bar (the bar thickness along the cross axis).
113    pub fn band_width(mut self, band_width: f32) -> Self {
114        self.band_width = band_width;
115        self
116    }
117
118    /// Set the baseline position of each bar (in pixels along the value axis).
119    pub fn base<F>(mut self, base: F) -> Self
120    where
121        F: Fn(&T) -> f32 + 'static,
122    {
123        self.base = Box::new(base);
124        self
125    }
126
127    /// Set the value-end position of each bar (in pixels along the value axis).
128    pub fn value<F>(mut self, value: F) -> Self
129    where
130        F: Fn(&T) -> Option<f32> + 'static,
131    {
132        self.value = Box::new(value);
133        self
134    }
135
136    /// Set the fill of each bar.
137    ///
138    /// The closure receives the datum, the bar's painted frame (`Bounds<f32>`)
139    /// in raw pixel coordinates relative to the plot bounds origin, and the
140    /// bar's [`BarAlignment`] (so callers can branch on orientation, e.g. flip
141    /// a gradient angle). Callers wishing to derive normalized or chart-relative
142    /// coordinates from this frame should do so themselves.
143    ///
144    /// Accepts any type convertible to [`Background`], including solid colors and
145    /// fully-specified [`gpui::linear_gradient`] values. The background is used
146    /// verbatim — the gradient angle is not adjusted for bar orientation.
147    pub fn fill<F, B>(mut self, fill: F) -> Self
148    where
149        F: Fn(&T, Bounds<f32>, BarAlignment) -> B + 'static,
150        B: Into<Background>,
151    {
152        self.fill = Box::new(move |v, frame, alignment| fill(v, frame, alignment).into());
153        self
154    }
155
156    /// Set the label of the Bar.
157    pub fn label<F>(mut self, label: F) -> Self
158    where
159        F: Fn(&T, Point<Pixels>) -> Vec<Text> + 'static,
160    {
161        self.label = Some(Box::new(label));
162        self
163    }
164
165    /// Set the corner radii applied to every bar rectangle.
166    ///
167    /// Use [`Corners::all`] for uniform rounding, or construct `Corners` manually to
168    /// round only specific corners (e.g. just the tip end of each bar).
169    pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
170        self.corner_radii = corner_radii.into();
171        self
172    }
173
174    fn path(&self, bounds: &Bounds<Pixels>) -> (Vec<PaintQuad>, PlotLabel) {
175        let origin = bounds.origin;
176        let mut graph = vec![];
177        let mut labels = vec![];
178
179        for v in &self.data {
180            let Some(cross) = (self.cross)(v) else {
181                continue;
182            };
183            let Some(value) = (self.value)(v) else {
184                continue;
185            };
186            let base = (self.base)(v);
187
188            let bw = self.band_width;
189            let (frame, p1, p2) = if self.alignment.is_vertical() {
190                let x0 = cross;
191                let x1 = cross + bw;
192                let y_min = value.min(base);
193                let y_max = value.max(base);
194                let frame = Bounds {
195                    origin: Point::new(x0, y_min),
196                    size: Size::new(x1 - x0, y_max - y_min),
197                };
198                (
199                    frame,
200                    origin_point(px(x0), px(y_min), origin),
201                    origin_point(px(x1), px(y_max), origin),
202                )
203            } else {
204                let y0 = cross;
205                let y1 = cross + bw;
206                let x_min = value.min(base);
207                let x_max = value.max(base);
208                let frame = Bounds {
209                    origin: Point::new(x_min, y0),
210                    size: Size::new(x_max - x_min, y1 - y0),
211                };
212                (
213                    frame,
214                    origin_point(px(x_min), px(y0), origin),
215                    origin_point(px(x_max), px(y1), origin),
216                )
217            };
218
219            let bg = (self.fill)(v, frame, self.alignment);
220            graph.push(fill(Bounds::from_corners(p1, p2), bg).corner_radii(self.corner_radii));
221
222            if let Some(label) = &self.label {
223                let label_origin = label_origin(self.alignment, cross, base, value, bw);
224                labels.extend(label(v, label_origin));
225            }
226        }
227
228        (graph, PlotLabel::new(labels))
229    }
230
231    /// Paint the Bar.
232    pub fn paint(&self, bounds: &Bounds<Pixels>, window: &mut Window, cx: &mut App) {
233        let (graph, labels) = self.path(bounds);
234        for quad in graph {
235            window.paint_quad(quad);
236        }
237        labels.paint(bounds, window, cx);
238    }
239}
240
241/// Origin point for a bar label, positioned outside the bar at the value end.
242///
243/// The caller chooses the [`gpui::TextAlign`] (typically `Center` for vertical
244/// bars, `Left` for `BarAlignment::Left`, `Right` for `BarAlignment::Right`).
245fn label_origin(
246    alignment: BarAlignment,
247    cross: f32,
248    base: f32,
249    value: f32,
250    band_width: f32,
251) -> Point<Pixels> {
252    match alignment {
253        BarAlignment::Bottom => {
254            let cx = cross + band_width / 2.;
255            // Normal: value < base (bar grows up). Label above bar end.
256            if value <= base {
257                point(px(cx), px(value - TEXT_HEIGHT))
258            } else {
259                point(px(cx), px(value + TEXT_GAP))
260            }
261        }
262        BarAlignment::Top => {
263            let cx = cross + band_width / 2.;
264            // Normal: value > base (bar grows down). Label below bar end.
265            if value >= base {
266                point(px(cx), px(value + TEXT_GAP))
267            } else {
268                point(px(cx), px(value - TEXT_HEIGHT))
269            }
270        }
271        BarAlignment::Left => {
272            // Vertical centering: text origin is the top of the glyph cell.
273            let cy = cross + band_width / 2. - TEXT_SIZE / 2.;
274            // Normal: value > base (bar grows right). Label to the right of bar end.
275            if value >= base {
276                point(px(value + TEXT_GAP), px(cy))
277            } else {
278                point(px(value - TEXT_GAP), px(cy))
279            }
280        }
281        BarAlignment::Right => {
282            let cy = cross + band_width / 2. - TEXT_SIZE / 2.;
283            // Normal: value < base (bar grows left). Label to the left of bar end.
284            if value <= base {
285                point(px(value - TEXT_GAP), px(cy))
286            } else {
287                point(px(value + TEXT_GAP), px(cy))
288            }
289        }
290    }
291}