Skip to main content

gpui_base/plot/
label.rs

1use std::fmt::Debug;
2
3use gpui::{
4    App, Bounds, FontWeight, Hsla, Pixels, Point, ShapedLine, SharedString, TextAlign, TextRun,
5    Window, point, px,
6};
7
8use super::origin_point;
9
10/// The default label font size, in pixels, of [`Text`] and
11/// [`AxisText`](super::AxisText).
12///
13/// A default, not a rule: a styled layer passes its own size through
14/// `font_size`, and reserves axis space with [`axis_gutter`](super::axis_gutter)
15/// for that size.
16pub const TEXT_SIZE: f32 = 10.;
17/// The default gap, in pixels, between a label and what it labels.
18pub const TEXT_GAP: f32 = 2.;
19/// The default height, in pixels, a label line takes: [`TEXT_SIZE`] plus
20/// [`TEXT_GAP`].
21pub const TEXT_HEIGHT: f32 = TEXT_SIZE + TEXT_GAP;
22
23fn shape_label(
24    text: &SharedString,
25    font_size: Pixels,
26    color: Hsla,
27    window: &mut Window,
28) -> ShapedLine {
29    let text_run = TextRun {
30        len: text.len(),
31        font: window.text_style().font(),
32        color,
33        background_color: None,
34        underline: None,
35        strikethrough: None,
36    };
37    window
38        .text_system()
39        .shape_line(text.clone(), font_size, &[text_run], None)
40}
41
42/// Returns the rendered width of `text` at `font_size` using the window's
43/// current text style.  Used for layout calculations that need to reserve
44/// space for labels before the scale ranges are fixed.
45pub fn measure_text_width(text: &SharedString, font_size: Pixels, window: &mut Window) -> f32 {
46    if text.is_empty() {
47        return 0.;
48    }
49    shape_label(
50        text,
51        font_size,
52        Hsla {
53            h: 0.,
54            s: 0.,
55            l: 0.,
56            a: 1.,
57        },
58        window,
59    )
60    .width()
61    .as_f32()
62}
63
64/// Truncate `text` with a trailing ellipsis so it fits within `max_width` at
65/// `font_size`. Returns `text` unchanged when it already fits; returns just
66/// the ellipsis when not even one character fits. `max_width <= 0` is treated
67/// as "no budget" and returns `text` unchanged (the caller has no room to
68/// reason about).
69pub fn truncate_text_to_width(
70    text: &SharedString,
71    font_size: Pixels,
72    max_width: f32,
73    window: &mut Window,
74) -> SharedString {
75    if max_width <= 0. || measure_text_width(text, font_size, window) <= max_width {
76        return text.clone();
77    }
78
79    const ELLIPSIS: &str = "…";
80    // Byte offsets of every char boundary after the first char; cutting at
81    // `cuts[k]` keeps the first `k + 1` chars.
82    let cuts: Vec<usize> = text.char_indices().skip(1).map(|(i, _)| i).collect();
83
84    // Binary search the largest prefix whose text + ellipsis still fits.
85    let mut best: Option<usize> = None;
86    let (mut lo, mut hi) = (0usize, cuts.len());
87    while lo < hi {
88        let mid = (lo + hi) / 2;
89        let candidate = SharedString::from(format!("{}{ELLIPSIS}", &text[..cuts[mid]]));
90        if measure_text_width(&candidate, font_size, window) <= max_width {
91            best = Some(mid);
92            lo = mid + 1;
93        } else {
94            hi = mid;
95        }
96    }
97
98    match best {
99        Some(mid) => SharedString::from(format!("{}{ELLIPSIS}", &text[..cuts[mid]])),
100        None => SharedString::from(ELLIPSIS),
101    }
102}
103
104/// A label [`PlotLabel`] draws: its text, where it sits relative to the plot
105/// origin and how it is drawn. `font_size` defaults to [`TEXT_SIZE`].
106#[non_exhaustive]
107pub struct Text {
108    pub text: SharedString,
109    pub origin: Point<Pixels>,
110    pub color: Hsla,
111    pub font_size: Pixels,
112    pub font_weight: FontWeight,
113    pub align: TextAlign,
114}
115
116impl Text {
117    pub fn new<T>(text: impl Into<SharedString>, origin: Point<T>, color: Hsla) -> Self
118    where
119        T: Default + Clone + Copy + Debug + PartialEq + Into<Pixels>,
120    {
121        let origin = point(origin.x.into(), origin.y.into());
122
123        Self {
124            text: text.into(),
125            origin,
126            color,
127            font_size: TEXT_SIZE.into(),
128            font_weight: FontWeight::NORMAL,
129            align: TextAlign::Left,
130        }
131    }
132
133    /// Set the font size of the Text.
134    pub fn font_size(mut self, font_size: impl Into<Pixels>) -> Self {
135        self.font_size = font_size.into();
136        self
137    }
138
139    /// Set the font weight of the Text.
140    pub fn font_weight(mut self, font_weight: FontWeight) -> Self {
141        self.font_weight = font_weight;
142        self
143    }
144
145    /// Set the alignment of the Text.
146    pub fn align(mut self, align: TextAlign) -> Self {
147        self.align = align;
148        self
149    }
150}
151
152impl<I> From<I> for PlotLabel
153where
154    I: Iterator<Item = Text>,
155{
156    fn from(items: I) -> Self {
157        Self::new(items.collect())
158    }
159}
160
161#[derive(Default)]
162pub struct PlotLabel(Vec<Text>);
163
164impl PlotLabel {
165    pub fn new(items: Vec<Text>) -> Self {
166        Self(items)
167    }
168
169    /// Paint the Label.
170    pub fn paint(&self, bounds: &Bounds<Pixels>, window: &mut Window, cx: &mut App) {
171        for Text {
172            text,
173            origin,
174            color,
175            font_size,
176            font_weight: _,
177            align,
178        } in self.0.iter()
179        {
180            let origin = origin_point(origin.x, origin.y, bounds.origin);
181
182            let line = shape_label(text, *font_size, *color, window);
183            let origin = match align {
184                TextAlign::Left => origin,
185                TextAlign::Right => origin - point(line.width(), px(0.)),
186                _ => origin - point(line.width() / 2., px(0.)),
187            };
188            let _ = line.paint(origin, *font_size, *align, None, window, cx);
189        }
190    }
191}