Skip to main content

plotlars_core/components/
text.rs

1use crate::components::Rgb;
2
3/// A structure representing text with customizable content, font, size, and color.
4///
5/// # Example
6///
7/// ```rust
8/// use polars::prelude::*;
9/// use plotlars::{Axis, BarPlot, Plot, Text, Rgb};
10///
11/// let dataset = df![
12///         "label" => &[""],
13///         "value" => &[0],
14///     ]
15///     .unwrap();
16///
17/// let axis = Axis::new()
18///     .tick_values(vec![]);
19///
20/// BarPlot::builder()
21///     .data(&dataset)
22///     .labels("label")
23///     .values("value")
24///     .plot_title(
25///         Text::from("Title")
26///             .x(0.1)
27///             .color(Rgb(178, 34, 34))
28///             .size(30)
29///             .font("Zapfino")
30///     )
31///     .x_title(
32///         Text::from("X")
33///             .color(Rgb(65, 105, 225))
34///             .size(20)
35///             .font("Marker Felt")
36///     )
37///     .y_title(
38///         Text::from("Y")
39///             .color(Rgb(255, 140, 0))
40///             .size(20)
41///             .font("Arial Black")
42///     )
43///     .x_axis(&axis)
44///     .y_axis(&axis)
45///     .build()
46///     .plot();
47/// ```
48/// ![Example](https://imgur.com/4outoUQ.png)
49#[derive(Clone)]
50pub struct Text {
51    pub content: String,
52    pub font: String,
53    pub size: usize,
54    pub color: Rgb,
55    pub x: f64,
56    pub y: f64,
57}
58
59impl Default for Text {
60    /// Provides default values for the `Text` struct.
61    ///
62    /// - `content`: An empty string.
63    /// - `font`: An empty string.
64    /// - `size`: `12` (reasonable default for visibility).
65    /// - `color`: Default `Rgb` value.
66    /// - `x`: `0.5`.
67    /// - `y`: `0.9`.
68    fn default() -> Self {
69        Text {
70            content: String::new(),
71            font: String::new(),
72            size: 12,
73            color: Rgb::default(),
74            x: 0.5,
75            y: 0.9,
76        }
77    }
78}
79
80impl Text {
81    /// Creates a new `Text` instance from the given content.
82    ///
83    /// # Argument
84    ///
85    /// * `content` - A value that can be converted into a `String`, representing the textual content.
86    pub fn from(content: impl Into<String>) -> Self {
87        Self {
88            content: content.into(),
89            ..Default::default()
90        }
91    }
92
93    /// Sets the font of the text.
94    ///
95    /// # Argument
96    ///
97    /// * `font` - A value that can be converted into a `String`, representing the font name.
98    pub fn font(mut self, font: impl Into<String>) -> Self {
99        self.font = font.into();
100        self
101    }
102
103    /// Sets the size of the text.
104    ///
105    /// # Argument
106    ///
107    /// * `size` - A `usize` value specifying the font size.
108    pub fn size(mut self, size: usize) -> Self {
109        self.size = size;
110        self
111    }
112
113    /// Sets the color of the text.
114    ///
115    /// # Argument
116    ///
117    /// * `color` - An `Rgb` value specifying the color of the text.
118    pub fn color(mut self, color: Rgb) -> Self {
119        self.color = color;
120        self
121    }
122
123    /// Sets the x-coordinate position of the text.
124    ///
125    /// # Argument
126    ///
127    /// * `x` - A `f64` value specifying the horizontal position.
128    pub fn x(mut self, x: f64) -> Self {
129        self.x = x;
130        self
131    }
132
133    /// Sets the y-coordinate position of the text.
134    ///
135    /// # Argument
136    ///
137    /// * `y` - A `f64` value specifying the vertical position.
138    pub fn y(mut self, y: f64) -> Self {
139        self.y = y;
140        self
141    }
142
143    pub fn has_custom_position(&self) -> bool {
144        const EPSILON: f64 = 1e-6;
145        (self.x - 0.5).abs() > EPSILON || (self.y - 0.9).abs() > EPSILON
146    }
147
148    /// Apply default positioning for plot titles (x=0.5, y=0.95 - centered above)
149    pub fn with_plot_title_defaults(mut self) -> Self {
150        const EPSILON: f64 = 1e-6;
151        let y_is_default = (self.y - 0.9).abs() < EPSILON;
152
153        if y_is_default {
154            self.y = 0.95;
155        }
156
157        self
158    }
159
160    /// Apply default positioning for subplot titles (x=0.5, y=1.1 - centered above, higher than overall)
161    pub fn with_subplot_title_defaults(mut self) -> Self {
162        const EPSILON: f64 = 1e-6;
163        let y_is_default = (self.y - 0.9).abs() < EPSILON;
164        let y_is_plot_default = (self.y - 0.95).abs() < EPSILON;
165
166        // Override both Text::default (0.9) and plot_title default (0.95)
167        if y_is_default || y_is_plot_default {
168            self.y = 1.1;
169        }
170
171        self
172    }
173
174    /// Apply default positioning for x-axis titles (x=0.5, y=-0.15 - centered below)
175    pub fn with_x_title_defaults(mut self) -> Self {
176        const EPSILON: f64 = 1e-6;
177        let y_is_default = (self.y - 0.9).abs() < EPSILON;
178
179        if y_is_default {
180            self.y = -0.15;
181        }
182
183        self
184    }
185
186    /// Apply default positioning for y-axis titles (x=-0.08, y=0.5 - left side, vertically centered)
187    pub fn with_y_title_defaults(mut self) -> Self {
188        const EPSILON: f64 = 1e-6;
189        let x_is_default = (self.x - 0.5).abs() < EPSILON;
190        let y_is_default = (self.y - 0.9).abs() < EPSILON;
191
192        if x_is_default {
193            self.x = -0.08;
194        }
195
196        if y_is_default {
197            self.y = 0.5;
198        }
199
200        self
201    }
202
203    /// Apply default positioning for x-axis title annotations
204    /// Used when user sets custom position and annotation mode is triggered
205    /// Ensures unset coordinates get appropriate axis defaults
206    pub fn with_x_title_defaults_for_annotation(mut self) -> Self {
207        const EPSILON: f64 = 1e-6;
208        let x_is_default = (self.x - 0.5).abs() < EPSILON;
209        let y_is_default = (self.y - 0.9).abs() < EPSILON;
210
211        if x_is_default {
212            self.x = 0.5;
213        }
214
215        if y_is_default {
216            self.y = -0.15;
217        }
218
219        self
220    }
221
222    /// Apply default positioning for y-axis title annotations
223    /// Used when user sets custom position and annotation mode is triggered
224    /// Ensures unset coordinates get appropriate axis defaults
225    pub fn with_y_title_defaults_for_annotation(mut self) -> Self {
226        const EPSILON: f64 = 1e-6;
227        let x_is_default = (self.x - 0.5).abs() < EPSILON;
228        let y_is_default = (self.y - 0.9).abs() < EPSILON;
229
230        if x_is_default {
231            self.x = -0.08;
232        }
233
234        if y_is_default {
235            self.y = 0.5;
236        }
237
238        self
239    }
240}
241
242impl From<&str> for Text {
243    fn from(content: &str) -> Self {
244        Self::from(content.to_string())
245    }
246}
247
248impl From<String> for Text {
249    fn from(content: String) -> Self {
250        Self::from(content)
251    }
252}
253
254impl From<&String> for Text {
255    fn from(content: &String) -> Self {
256        Self::from(content)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::components::Rgb;
264
265    fn assert_float_eq(a: f64, b: f64) {
266        assert!(
267            (a - b).abs() < 1e-6,
268            "expected {b}, got {a} (diff {})",
269            (a - b).abs()
270        );
271    }
272
273    #[test]
274    fn test_from_str() {
275        let t = Text::from("hello");
276        assert_eq!(t.content, "hello");
277        assert_eq!(t.font, "");
278        assert_eq!(t.size, 12);
279        assert_eq!(t.color.0, 0);
280        assert_eq!(t.color.1, 0);
281        assert_eq!(t.color.2, 0);
282        assert_float_eq(t.x, 0.5);
283        assert_float_eq(t.y, 0.9);
284    }
285
286    #[test]
287    fn test_from_string() {
288        let t: Text = String::from("world").into();
289        assert_eq!(t.content, "world");
290    }
291
292    #[test]
293    fn test_from_ref_string() {
294        let s = String::from("ref");
295        let t: Text = (&s).into();
296        assert_eq!(t.content, "ref");
297    }
298
299    #[test]
300    fn test_default_values() {
301        let t = Text::default();
302        assert_eq!(t.content, "");
303        assert_eq!(t.font, "");
304        assert_eq!(t.size, 12);
305        assert_eq!(t.color.0, 0);
306        assert_eq!(t.color.1, 0);
307        assert_eq!(t.color.2, 0);
308        assert_float_eq(t.x, 0.5);
309        assert_float_eq(t.y, 0.9);
310    }
311
312    #[test]
313    fn test_font() {
314        let t = Text::from("x").font("Arial");
315        assert_eq!(t.font, "Arial");
316        assert_eq!(t.size, 12);
317    }
318
319    #[test]
320    fn test_size() {
321        let t = Text::from("x").size(20);
322        assert_eq!(t.size, 20);
323        assert_eq!(t.font, "");
324    }
325
326    #[test]
327    fn test_color() {
328        let t = Text::from("x").color(Rgb(1, 2, 3));
329        assert_eq!(t.color.0, 1);
330        assert_eq!(t.color.1, 2);
331        assert_eq!(t.color.2, 3);
332    }
333
334    #[test]
335    fn test_x() {
336        let t = Text::from("x").x(0.1);
337        assert_float_eq(t.x, 0.1);
338        assert_float_eq(t.y, 0.9);
339    }
340
341    #[test]
342    fn test_y() {
343        let t = Text::from("x").y(0.2);
344        assert_float_eq(t.y, 0.2);
345        assert_float_eq(t.x, 0.5);
346    }
347
348    #[test]
349    fn test_builder_chaining() {
350        let t = Text::from("chained")
351            .font("Courier")
352            .size(24)
353            .color(Rgb(10, 20, 30))
354            .x(0.3)
355            .y(0.7);
356        assert_eq!(t.content, "chained");
357        assert_eq!(t.font, "Courier");
358        assert_eq!(t.size, 24);
359        assert_eq!(t.color.0, 10);
360        assert_eq!(t.color.1, 20);
361        assert_eq!(t.color.2, 30);
362        assert_float_eq(t.x, 0.3);
363        assert_float_eq(t.y, 0.7);
364    }
365
366    #[test]
367    fn test_has_custom_position_default() {
368        let t = Text::default();
369        assert!(!t.has_custom_position());
370    }
371
372    #[test]
373    fn test_has_custom_position_x_changed() {
374        let t = Text::from("x").x(0.3);
375        assert!(t.has_custom_position());
376    }
377
378    #[test]
379    fn test_has_custom_position_epsilon() {
380        let t = Text::from("x").x(0.5 + 1e-7);
381        assert!(!t.has_custom_position());
382    }
383
384    #[test]
385    fn test_with_plot_title_defaults() {
386        let t = Text::from("title").with_plot_title_defaults();
387        assert_float_eq(t.y, 0.95);
388
389        let t2 = Text::from("title").y(0.7).with_plot_title_defaults();
390        assert_float_eq(t2.y, 0.7);
391    }
392
393    #[test]
394    fn test_with_x_title_defaults() {
395        let t = Text::from("x").with_x_title_defaults();
396        assert_float_eq(t.y, -0.15);
397    }
398
399    #[test]
400    fn test_with_y_title_defaults() {
401        let t = Text::from("y").with_y_title_defaults();
402        assert_float_eq(t.x, -0.08);
403        assert_float_eq(t.y, 0.5);
404
405        let t2 = Text::from("y").x(0.2).y(0.3).with_y_title_defaults();
406        assert_float_eq(t2.x, 0.2);
407        assert_float_eq(t2.y, 0.3);
408    }
409}