Skip to main content

gpui_base/plot/
axis.rs

1use gpui::{
2    App, Background, Bounds, FontWeight, Hsla, PathBuilder, Pixels, Point, SharedString, TextAlign,
3    Window, point, px,
4};
5
6use super::{
7    label::PlotLabel, label::TEXT_GAP, label::TEXT_HEIGHT, label::TEXT_SIZE, label::Text,
8    origin_point,
9};
10
11/// The x-axis gutter for labels at the default [`TEXT_SIZE`].
12#[deprecated(
13    since = "0.7.0",
14    note = "use `axis_gutter` with the label font size the chart draws"
15)]
16pub const AXIS_GAP: f32 = 18.;
17
18/// The space below (or above) an x-axis line that tick labels of `font_size`
19/// need: the gap [`PlotAxis`] leaves between the line and the labels, the
20/// labels themselves, and a trailing gap.
21///
22/// A chart reserves this much of its height for the axis. With the default
23/// [`TEXT_SIZE`] it is 18px; a styled layer drawing larger labels passes its
24/// own size so the plot shrinks to fit them.
25pub fn axis_gutter(font_size: Pixels) -> f32 {
26    font_size.as_f32() + TEXT_GAP * 4.
27}
28
29/// Which side of an axis line the tick labels render on.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
31pub enum AxisLabelSide {
32    /// X-axis: labels below the line. Y-axis: labels right of the line. (Default.)
33    #[default]
34    End,
35    /// X-axis: labels above the line. Y-axis: labels left of the line.
36    Start,
37}
38
39/// Where a chart draws the tick labels of its value axis.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41pub enum AxisLabelPlacement {
42    /// In a gutter beside the plot, which the plot shrinks to make room for. (Default.)
43    #[default]
44    Outside,
45    /// Over the plot's edge, beside the grid line each label reads, so the plot
46    /// keeps its full size.
47    Inside,
48}
49
50/// A tick label on a [`PlotAxis`]: its text, where along the axis it sits and
51/// how it is drawn. `font_size` defaults to [`TEXT_SIZE`].
52#[non_exhaustive]
53pub struct AxisText {
54    pub text: SharedString,
55    pub tick: Pixels,
56    pub color: Hsla,
57    pub font_size: Pixels,
58    pub align: TextAlign,
59}
60
61impl AxisText {
62    pub fn new(text: impl Into<SharedString>, tick: impl Into<Pixels>, color: Hsla) -> Self {
63        Self {
64            text: text.into(),
65            tick: tick.into(),
66            color,
67            font_size: TEXT_SIZE.into(),
68            align: TextAlign::Left,
69        }
70    }
71
72    pub fn font_size(mut self, font_size: impl Into<Pixels>) -> Self {
73        self.font_size = font_size.into();
74        self
75    }
76
77    pub fn align(mut self, align: TextAlign) -> Self {
78        self.align = align;
79        self
80    }
81}
82
83/// Axis lines and their tick labels.
84///
85/// The builders only record values: where the lines sit, which side their
86/// labels take and the labels themselves are combined when the axis paints,
87/// so they can be set in any order.
88pub struct PlotAxis {
89    x: Option<Pixels>,
90    x_labels: Vec<AxisText>,
91    x_axis: bool,
92    x_label_side: AxisLabelSide,
93    y: Option<Pixels>,
94    y_labels: Vec<AxisText>,
95    y_axis: bool,
96    y_label_side: AxisLabelSide,
97    stroke: Background,
98}
99
100impl Default for PlotAxis {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl PlotAxis {
107    pub fn new() -> Self {
108        Self {
109            x: None,
110            x_labels: Vec::new(),
111            x_axis: true,
112            x_label_side: AxisLabelSide::default(),
113            y: None,
114            y_labels: Vec::new(),
115            y_axis: false,
116            y_label_side: AxisLabelSide::default(),
117            stroke: Hsla::default().into(),
118        }
119    }
120
121    /// Place the x-axis line at `position` from the top of the plot. Without
122    /// it the x-axis draws neither its line nor its labels.
123    pub fn x(mut self, position: impl Into<Pixels>) -> Self {
124        self.x = Some(position.into());
125        self
126    }
127
128    /// Show or hide the x-axis line; its labels are drawn either way.
129    ///
130    /// Default is true.
131    pub fn x_axis(mut self, x_axis: bool) -> Self {
132        self.x_axis = x_axis;
133        self
134    }
135
136    /// Set the tick labels of the x-axis.
137    pub fn x_label(mut self, labels: impl IntoIterator<Item = AxisText>) -> Self {
138        self.x_labels = labels.into_iter().collect();
139        self
140    }
141
142    /// Set which side of the x-axis line tick labels render on.
143    pub fn x_label_side(mut self, side: AxisLabelSide) -> Self {
144        self.x_label_side = side;
145        self
146    }
147
148    /// Place the y-axis line at `position` from the left of the plot. Without
149    /// it the y-axis draws neither its line nor its labels.
150    pub fn y(mut self, position: impl Into<Pixels>) -> Self {
151        self.y = Some(position.into());
152        self
153    }
154
155    /// Show or hide the y-axis line; its labels are drawn either way.
156    ///
157    /// Default is false.
158    pub fn y_axis(mut self, y_axis: bool) -> Self {
159        self.y_axis = y_axis;
160        self
161    }
162
163    /// Set the tick labels of the y-axis.
164    pub fn y_label(mut self, labels: impl IntoIterator<Item = AxisText>) -> Self {
165        self.y_labels = labels.into_iter().collect();
166        self
167    }
168
169    /// Set which side of the y-axis line tick labels render on.
170    pub fn y_label_side(mut self, side: AxisLabelSide) -> Self {
171        self.y_label_side = side;
172        self
173    }
174
175    /// Set the stroke of the axis lines.
176    pub fn stroke(mut self, stroke: impl Into<Background>) -> Self {
177        self.stroke = stroke.into();
178        self
179    }
180
181    /// The x-axis labels placed against the line at `x`.
182    fn x_texts(&self, x: Pixels) -> Vec<Text> {
183        self.x_labels
184            .iter()
185            .map(|t| {
186                let y = match self.x_label_side {
187                    AxisLabelSide::End => x + px(TEXT_GAP * 3.),
188                    AxisLabelSide::Start => x - px(TEXT_GAP + TEXT_HEIGHT),
189                };
190                axis_text(t, point(t.tick, y))
191            })
192            .collect()
193    }
194
195    /// The y-axis labels placed against the line at `y`.
196    fn y_texts(&self, y: Pixels) -> Vec<Text> {
197        self.y_labels
198            .iter()
199            .map(|t| {
200                let x = match self.y_label_side {
201                    AxisLabelSide::End => y + px(TEXT_GAP),
202                    AxisLabelSide::Start => y - px(TEXT_GAP),
203                };
204                axis_text(t, point(x, t.tick - px(TEXT_SIZE / 2.)))
205            })
206            .collect()
207    }
208
209    fn draw_axis(&self, start_point: Point<Pixels>, end_point: Point<Pixels>, window: &mut Window) {
210        let mut builder = PathBuilder::stroke(px(1.));
211        builder.move_to(start_point);
212        builder.line_to(end_point);
213        if let Ok(path) = builder.build() {
214            window.paint_path(path, self.stroke);
215        }
216    }
217
218    /// Paint the Axis.
219    pub fn paint(&self, bounds: &Bounds<Pixels>, window: &mut Window, cx: &mut App) {
220        let origin = bounds.origin;
221
222        if let Some(x) = self.x {
223            if self.x_axis {
224                self.draw_axis(
225                    origin_point(px(0.), x, origin),
226                    origin_point(bounds.size.width, x, origin),
227                    window,
228                );
229            }
230            PlotLabel::new(self.x_texts(x)).paint(bounds, window, cx);
231        }
232
233        if let Some(y) = self.y {
234            if self.y_axis {
235                self.draw_axis(
236                    origin_point(y, px(0.), origin),
237                    origin_point(y, bounds.size.height, origin),
238                    window,
239                );
240            }
241            PlotLabel::new(self.y_texts(y)).paint(bounds, window, cx);
242        }
243    }
244}
245
246/// `label` as the text [`PlotLabel`] draws at `origin`.
247fn axis_text(label: &AxisText, origin: Point<Pixels>) -> Text {
248    Text::new(label.text.clone(), origin, label.color)
249        .font_size(label.font_size)
250        .font_weight(FontWeight::NORMAL)
251        .align(label.align)
252}
253
254#[cfg(test)]
255mod tests {
256    use gpui::{Hsla, px};
257
258    use super::*;
259
260    fn labels() -> Vec<AxisText> {
261        vec![
262            AxisText::new("a", px(10.), Hsla::default()),
263            AxisText::new("b", px(20.), Hsla::default()).align(TextAlign::Right),
264        ]
265    }
266
267    fn origins(texts: Vec<Text>) -> Vec<(SharedString, Point<Pixels>)> {
268        texts.into_iter().map(|t| (t.text, t.origin)).collect()
269    }
270
271    #[test]
272    fn builder_order_does_not_move_labels() {
273        let labels_first = PlotAxis::new()
274            .x_label(labels())
275            .x(px(50.))
276            .x_label_side(AxisLabelSide::Start)
277            .y_label(labels())
278            .y(px(30.))
279            .y_label_side(AxisLabelSide::Start);
280        let labels_last = PlotAxis::new()
281            .x_label_side(AxisLabelSide::Start)
282            .x(px(50.))
283            .x_label(labels())
284            .y_label_side(AxisLabelSide::Start)
285            .y(px(30.))
286            .y_label(labels());
287
288        assert_eq!(
289            origins(labels_first.x_texts(px(50.))),
290            origins(labels_last.x_texts(px(50.)))
291        );
292        assert_eq!(
293            origins(labels_first.y_texts(px(30.))),
294            origins(labels_last.y_texts(px(30.)))
295        );
296        // The side set after the labels still applies to them.
297        assert_eq!(
298            labels_first.x_texts(px(50.))[0].origin.y,
299            px(50. - TEXT_GAP - TEXT_HEIGHT)
300        );
301    }
302
303    #[test]
304    fn axis_gutter_fits_default_labels() {
305        assert_eq!(axis_gutter(px(TEXT_SIZE)), 18.);
306    }
307}