Skip to main content

egui_charts/chart/rendering/
watermark.rs

1//! Host watermarks: lines of text and an image, aligned anywhere in the price pane.
2
3use crate::config::FontWeight;
4use egui::{Align2, Color32, FontId, Painter, Rect, Shape, Ui, Vec2};
5
6/// One line of text of a [`Watermark`].
7#[derive(Clone, Debug, PartialEq)]
8pub struct WatermarkLine {
9    /// Text, on one line.
10    pub text: String,
11    /// Font size in points; the line is 1.2 times as tall.
12    pub size: f32,
13    /// Default [`FontWeight::Normal`].
14    pub weight: FontWeight,
15    /// Colour, usually translucent.
16    pub color: Color32,
17}
18
19impl WatermarkLine {
20    /// Creates a line of normal weight.
21    pub fn new(text: impl Into<String>, size: f32, color: Color32) -> Self {
22        Self {
23            text: text.into(),
24            size,
25            weight: FontWeight::Normal,
26            color,
27        }
28    }
29
30    /// Sets the weight.
31    #[must_use]
32    pub fn with_weight(mut self, weight: FontWeight) -> Self {
33        self.weight = weight;
34        self
35    }
36}
37
38/// An image and lines of text painted behind the series, stacked in that order and aligned
39/// together in the price pane; see [`Chart::set_watermark`](crate::Chart::set_watermark).
40#[derive(Clone, Debug)]
41pub struct Watermark {
42    /// Lines of text, top to bottom.
43    pub lines: Vec<WatermarkLine>,
44    /// Image above the lines. By default it is scaled to fit the pane less the padding,
45    /// keeping its aspect ratio; [`egui::Image::max_size`] limits it and
46    /// [`egui::Image::tint`] makes it translucent.
47    pub image: Option<egui::Image<'static>>,
48    /// Where the image and the lines sit in the pane, and how the lines align with each
49    /// other. Default [`Align2::CENTER_CENTER`].
50    pub align: Align2,
51    /// Distance from the pane's edges in points. Default 0.
52    pub padding: f32,
53}
54
55impl Default for Watermark {
56    fn default() -> Self {
57        Self {
58            lines: Vec::new(),
59            image: None,
60            align: Align2::CENTER_CENTER,
61            padding: 0.0,
62        }
63    }
64}
65
66impl Watermark {
67    /// Adds a line below the others.
68    #[must_use]
69    pub fn with_line(mut self, line: WatermarkLine) -> Self {
70        self.lines.push(line);
71        self
72    }
73
74    /// Sets the image.
75    #[must_use]
76    pub fn with_image(mut self, image: egui::Image<'static>) -> Self {
77        self.image = Some(image);
78        self
79    }
80
81    /// Sets the alignment.
82    #[must_use]
83    pub fn with_align(mut self, align: Align2) -> Self {
84        self.align = align;
85        self
86    }
87
88    /// Sets the distance from the pane's edges.
89    #[must_use]
90    pub fn with_padding(mut self, padding: f32) -> Self {
91        self.padding = padding;
92        self
93    }
94}
95
96/// Paints `watermark` in `rect`. An image still loading is left out until it has loaded.
97pub(crate) fn render_watermark(ui: &Ui, painter: &Painter, rect: Rect, watermark: &Watermark) {
98    let inner = rect.shrink(watermark.padding.max(0.0));
99    if !inner.is_positive() {
100        return;
101    }
102    let image = watermark.image.as_ref().and_then(|image| {
103        let size = image.load_and_calc_size(ui, inner.size())?;
104        Some((image, size))
105    });
106    let galleys: Vec<_> = watermark
107        .lines
108        .iter()
109        .filter(|line| line.size.is_finite() && line.size > 0.0)
110        .map(|line| {
111            let galley = painter.layout_no_wrap(
112                line.text.clone(),
113                FontId::proportional(line.size),
114                line.color,
115            );
116            (line, galley)
117        })
118        .collect();
119    let block = galleys.iter().fold(
120        image.map_or(Vec2::ZERO, |(_, size)| size),
121        |block, (line, galley)| Vec2::new(block.x.max(galley.size().x), block.y + 1.2 * line.size),
122    );
123    let block = watermark.align.align_size_within_rect(block, inner);
124    let row = |top: f32, size: Vec2| {
125        let column = Rect::from_min_max(egui::pos2(block.left(), top), block.right_bottom());
126        Align2([watermark.align.x(), egui::Align::Min]).align_size_within_rect(size, column)
127    };
128    let mut top = block.top();
129    if let Some((image, size)) = image {
130        image.paint_at(ui, row(top, size));
131        top += size.y;
132    }
133    for (line, galley) in galleys {
134        let height = 1.2 * line.size;
135        let at = row(top + (height - galley.size().y) / 2.0, galley.size()).min;
136        let text = Shape::galley(at, galley.clone(), line.color);
137        if line.weight == FontWeight::Bold {
138            // Synthetic weight, as drawn text uses, keeps host font families intact.
139            painter.add(Shape::galley(at + Vec2::new(0.6, 0.0), galley, line.color));
140        }
141        painter.add(text);
142        top += height;
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::widget::test_frames::{chart, shapes};
150    use egui::load::SizedTexture;
151
152    /// With 10 points of padding, a 100 by 50 image, a 40 point bold line (48 tall) and a 20
153    /// point line (24 tall) form a block 122 tall. Top, centre and bottom alignment put its top
154    /// at the padded pane's top, 61 above its centre and 122 above its bottom; each part sits on
155    /// its left edge, centre or right edge, and each line is centred in its row. The bold line is
156    /// painted twice, 0.6 points apart.
157    #[test]
158    fn watermark_stacks_its_image_and_lines_at_the_alignment() {
159        let (bold, plain) = (Color32::from_rgb(1, 2, 3), Color32::from_rgb(4, 5, 6));
160        let texture = egui::TextureId::User(7);
161        let ctx = egui::Context::default();
162        let mut chart = chart(&[100.0], &[110.0]);
163        for align in [
164            Align2::LEFT_TOP,
165            Align2::CENTER_CENTER,
166            Align2::RIGHT_BOTTOM,
167        ] {
168            chart.set_watermark(Some(
169                Watermark::default()
170                    .with_image(
171                        egui::Image::from_texture(SizedTexture::new(
172                            texture,
173                            Vec2::new(200.0, 100.0),
174                        ))
175                        .max_size(Vec2::new(100.0, 50.0)),
176                    )
177                    .with_line(WatermarkLine::new("AAPL", 40.0, bold).with_weight(FontWeight::Bold))
178                    .with_line(WatermarkLine::new("Apple Inc", 20.0, plain))
179                    .with_align(align)
180                    .with_padding(10.0),
181            ));
182            let painted = shapes(&ctx, &mut chart, 0.0, Vec::new());
183            let pane = chart.last_rendered_price_rect.shrink(10.0);
184            let top = match align.y() {
185                egui::Align::Min => pane.top(),
186                egui::Align::Center => pane.center().y - 61.0,
187                egui::Align::Max => pane.bottom() - 122.0,
188            };
189            let edge = |part: Rect| match align.x() {
190                egui::Align::Min => part.left() - pane.left(),
191                egui::Align::Center => part.center().x - pane.center().x,
192                egui::Align::Max => part.right() - pane.right(),
193            };
194            let image = painted
195                .iter()
196                .find(|shape| shape.texture_id() == texture)
197                .expect("the image")
198                .visual_bounding_rect();
199            assert_eq!(image.size(), Vec2::new(100.0, 50.0), "{align:?}");
200            // The image snaps to whole pixels, so a centred image may move half a pixel.
201            assert!((image.top() - top).abs() <= 0.5, "{align:?}");
202            assert!(edge(image).abs() < 0.01, "{align:?}");
203            for (color, centre, copies) in
204                [(bold, top + 50.0 + 24.0, 2), (plain, top + 122.0 - 12.0, 1)]
205            {
206                // The layout box, not the glyphs' ink.
207                let texts: Vec<_> = painted
208                    .iter()
209                    .filter_map(|shape| match shape {
210                        Shape::Text(t) if t.fallback_color == color => {
211                            Some(t.galley.rect.translate(t.pos.to_vec2()))
212                        }
213                        _ => None,
214                    })
215                    .collect();
216                assert_eq!(texts.len(), copies, "{align:?}");
217                assert!((texts[0].center().y - centre).abs() < 0.01, "{align:?}");
218                assert!(edge(texts[copies - 1]).abs() < 0.01, "{align:?}");
219            }
220        }
221    }
222}