tui_realm_stdlib/components/
chart.rs

1//! ## Chart
2//!
3//! A component to plot one or more dataset in a cartesian coordinate system
4
5use tuirealm::command::{Cmd, CmdResult, Direction, Position};
6use tuirealm::props::{
7    Alignment, AttrValue, Attribute, Borders, Color, Dataset, PropPayload, PropValue, Props, Style,
8};
9use tuirealm::ratatui::text::Line;
10use tuirealm::ratatui::{
11    layout::Rect,
12    text::Span,
13    widgets::{Axis, Chart as TuiChart, Dataset as TuiDataset},
14};
15use tuirealm::{Frame, MockComponent, State};
16
17// -- Props
18use super::props::{
19    CHART_X_BOUNDS, CHART_X_LABELS, CHART_X_STYLE, CHART_X_TITLE, CHART_Y_BOUNDS, CHART_Y_LABELS,
20    CHART_Y_STYLE, CHART_Y_TITLE,
21};
22
23/// ### ChartStates
24///
25/// chart states
26#[derive(Default)]
27pub struct ChartStates {
28    pub cursor: usize,
29    pub data: Vec<Dataset>,
30}
31
32impl ChartStates {
33    /// ### move_cursor_left
34    ///
35    /// Move cursor to the left
36    pub fn move_cursor_left(&mut self) {
37        if self.cursor > 0 {
38            self.cursor -= 1;
39        }
40    }
41
42    /// ### move_cursor_right
43    ///
44    /// Move cursor to the right
45    pub fn move_cursor_right(&mut self, data_len: usize) {
46        if data_len > 0 && self.cursor + 1 < data_len {
47            self.cursor += 1;
48        }
49    }
50
51    /// ### reset_cursor
52    ///
53    /// Reset cursor to 0
54    pub fn reset_cursor(&mut self) {
55        self.cursor = 0;
56    }
57
58    /// ### cursor_at_end
59    ///
60    /// Move cursor to the end of the chart
61    pub fn cursor_at_end(&mut self, data_len: usize) {
62        if data_len > 0 {
63            self.cursor = data_len - 1;
64        } else {
65            self.cursor = 0;
66        }
67    }
68}
69
70// -- component
71
72/// ### Chart
73///
74/// A component to display a chart on a cartesian coordinate system.
75/// The chart can work both in "active" and "disabled" mode.
76///
77/// #### Disabled mode
78///
79/// When in disabled mode, the chart won't be interactive, so you won't be able to move through data using keys.
80/// If you have more data than the maximum amount of bars that can be displayed, you'll have to update data to display the remaining entries
81///
82/// #### Active mode
83///
84/// While in active mode (default) you can put as many entries as you wish. You can move with arrows and END/HOME keys
85#[derive(Default)]
86#[must_use]
87pub struct Chart {
88    props: Props,
89    pub states: ChartStates,
90}
91
92impl Chart {
93    pub fn foreground(mut self, fg: Color) -> Self {
94        self.props.set(Attribute::Foreground, AttrValue::Color(fg));
95        self
96    }
97
98    pub fn background(mut self, bg: Color) -> Self {
99        self.props.set(Attribute::Background, AttrValue::Color(bg));
100        self
101    }
102
103    pub fn borders(mut self, b: Borders) -> Self {
104        self.props.set(Attribute::Borders, AttrValue::Borders(b));
105        self
106    }
107
108    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
109        self.props
110            .set(Attribute::Title, AttrValue::Title((t.into(), a)));
111        self
112    }
113
114    pub fn disabled(mut self, disabled: bool) -> Self {
115        self.attr(Attribute::Disabled, AttrValue::Flag(disabled));
116        self
117    }
118
119    pub fn inactive(mut self, s: Style) -> Self {
120        self.props.set(Attribute::FocusStyle, AttrValue::Style(s));
121        self
122    }
123
124    pub fn data(mut self, data: impl IntoIterator<Item = Dataset>) -> Self {
125        self.props.set(
126            Attribute::Dataset,
127            AttrValue::Payload(PropPayload::Vec(
128                data.into_iter().map(PropValue::Dataset).collect(),
129            )),
130        );
131        self
132    }
133
134    pub fn x_bounds(mut self, bounds: (f64, f64)) -> Self {
135        self.props.set(
136            Attribute::Custom(CHART_X_BOUNDS),
137            AttrValue::Payload(PropPayload::Tup2((
138                PropValue::F64(bounds.0),
139                PropValue::F64(bounds.1),
140            ))),
141        );
142        self
143    }
144
145    pub fn y_bounds(mut self, bounds: (f64, f64)) -> Self {
146        self.props.set(
147            Attribute::Custom(CHART_Y_BOUNDS),
148            AttrValue::Payload(PropPayload::Tup2((
149                PropValue::F64(bounds.0),
150                PropValue::F64(bounds.1),
151            ))),
152        );
153        self
154    }
155
156    pub fn x_labels(mut self, labels: &[&str]) -> Self {
157        self.attr(
158            Attribute::Custom(CHART_X_LABELS),
159            AttrValue::Payload(PropPayload::Vec(
160                labels
161                    .iter()
162                    .map(|x| PropValue::Str((*x).to_string()))
163                    .collect(),
164            )),
165        );
166        self
167    }
168
169    pub fn y_labels(mut self, labels: &[&str]) -> Self {
170        self.attr(
171            Attribute::Custom(CHART_Y_LABELS),
172            AttrValue::Payload(PropPayload::Vec(
173                labels
174                    .iter()
175                    .map(|x| PropValue::Str((*x).to_string()))
176                    .collect(),
177            )),
178        );
179        self
180    }
181
182    pub fn x_style(mut self, s: Style) -> Self {
183        self.attr(Attribute::Custom(CHART_X_STYLE), AttrValue::Style(s));
184        self
185    }
186
187    pub fn y_style(mut self, s: Style) -> Self {
188        self.attr(Attribute::Custom(CHART_Y_STYLE), AttrValue::Style(s));
189        self
190    }
191
192    pub fn x_title<S: Into<String>>(mut self, t: S) -> Self {
193        self.props.set(
194            Attribute::Custom(CHART_X_TITLE),
195            AttrValue::String(t.into()),
196        );
197        self
198    }
199
200    pub fn y_title<S: Into<String>>(mut self, t: S) -> Self {
201        self.props.set(
202            Attribute::Custom(CHART_Y_TITLE),
203            AttrValue::String(t.into()),
204        );
205        self
206    }
207
208    fn is_disabled(&self) -> bool {
209        self.props
210            .get_or(Attribute::Disabled, AttrValue::Flag(false))
211            .unwrap_flag()
212    }
213
214    /// ### max_dataset_len
215    ///
216    /// Get the maximum len among the datasets
217    fn max_dataset_len(&self) -> usize {
218        self.props
219            .get(Attribute::Dataset)
220            .and_then(|x| {
221                x.unwrap_payload()
222                    .unwrap_vec()
223                    .iter()
224                    .cloned()
225                    .map(|x| x.unwrap_dataset().get_data().len())
226                    .max()
227            })
228            .unwrap_or(0)
229    }
230
231    /// ### data
232    ///
233    /// Get data to be displayed, starting from provided index at `start`
234    fn get_data(&mut self, start: usize) -> Vec<TuiDataset> {
235        self.states.data = self
236            .props
237            .get(Attribute::Dataset)
238            .map(|x| {
239                x.unwrap_payload()
240                    .unwrap_vec()
241                    .into_iter()
242                    .map(|x| x.unwrap_dataset())
243                    .collect()
244            })
245            .unwrap_or_default();
246        self.states
247            .data
248            .iter()
249            .map(|x| Self::get_tui_dataset(x, start))
250            .collect()
251    }
252}
253
254impl<'a> Chart {
255    /// ### get_tui_dataset
256    ///
257    /// Create tui_dataset from dataset
258    /// Only elements from `start` to the end
259    fn get_tui_dataset(dataset: &'a Dataset, start: usize) -> TuiDataset<'a> {
260        let points = dataset.get_data();
261
262        // Prepare data storage
263        TuiDataset::default()
264            .name(dataset.name.clone())
265            .marker(dataset.marker)
266            .graph_type(dataset.graph_type)
267            .style(dataset.style)
268            .data(&points[start..])
269    }
270}
271
272impl MockComponent for Chart {
273    fn view(&mut self, render: &mut Frame, area: Rect) {
274        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
275            let foreground = self
276                .props
277                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
278                .unwrap_color();
279            let background = self
280                .props
281                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
282                .unwrap_color();
283            let borders = self
284                .props
285                .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
286                .unwrap_borders();
287            let title = self
288                .props
289                .get_ref(Attribute::Title)
290                .and_then(|x| x.as_title())
291                // this needs to be cloned as "self" is later mutably borrowed, while this immutably borrows "self"
292                .cloned();
293            let focus = self
294                .props
295                .get_or(Attribute::Focus, AttrValue::Flag(false))
296                .unwrap_flag();
297            let inactive_style = self
298                .props
299                .get(Attribute::FocusStyle)
300                .map(|x| x.unwrap_style());
301            let active: bool = if self.is_disabled() { true } else { focus };
302            let div = crate::utils::get_block(borders, title.as_ref(), active, inactive_style);
303            // Create widget
304            // -- x axis
305            let mut x_axis: Axis = Axis::default();
306            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
307                .props
308                .get(Attribute::Custom(CHART_X_BOUNDS))
309                .map(|x| x.unwrap_payload().unwrap_tup2())
310            {
311                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
312                x_axis = x_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
313            }
314            if let Some(PropPayload::Vec(labels)) = self
315                .props
316                .get(Attribute::Custom(CHART_X_LABELS))
317                .map(|x| x.unwrap_payload())
318            {
319                x_axis = x_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
320            }
321            if let Some(s) = self
322                .props
323                .get(Attribute::Custom(CHART_X_STYLE))
324                .map(|x| x.unwrap_style())
325            {
326                x_axis = x_axis.style(s);
327            }
328            if let Some(title) = self
329                .props
330                .get(Attribute::Custom(CHART_X_TITLE))
331                .map(|x| x.unwrap_string())
332            {
333                x_axis = x_axis.title(Span::styled(
334                    title,
335                    Style::default().fg(foreground).bg(background),
336                ));
337            }
338            // -- y axis
339            let mut y_axis: Axis = Axis::default();
340            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
341                .props
342                .get(Attribute::Custom(CHART_Y_BOUNDS))
343                .map(|x| x.unwrap_payload().unwrap_tup2())
344            {
345                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
346                y_axis = y_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
347            }
348            if let Some(PropPayload::Vec(labels)) = self
349                .props
350                .get(Attribute::Custom(CHART_Y_LABELS))
351                .map(|x| x.unwrap_payload())
352            {
353                y_axis = y_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
354            }
355            if let Some(s) = self
356                .props
357                .get(Attribute::Custom(CHART_Y_STYLE))
358                .map(|x| x.unwrap_style())
359            {
360                y_axis = y_axis.style(s);
361            }
362            if let Some(title) = self
363                .props
364                .get(Attribute::Custom(CHART_Y_TITLE))
365                .map(|x| x.unwrap_string())
366            {
367                y_axis = y_axis.title(Span::styled(
368                    title,
369                    Style::default().fg(foreground).bg(background),
370                ));
371            }
372            // Get data
373            let data: Vec<TuiDataset> = self.get_data(self.states.cursor);
374            // Build widget
375            let widget: TuiChart = TuiChart::new(data).block(div).x_axis(x_axis).y_axis(y_axis);
376            // Render
377            render.render_widget(widget, area);
378        }
379    }
380
381    fn query(&self, attr: Attribute) -> Option<AttrValue> {
382        self.props.get(attr)
383    }
384
385    fn attr(&mut self, attr: Attribute, value: AttrValue) {
386        self.props.set(attr, value);
387        self.states.reset_cursor();
388    }
389
390    fn perform(&mut self, cmd: Cmd) -> CmdResult {
391        if !self.is_disabled() {
392            match cmd {
393                Cmd::Move(Direction::Left) => {
394                    self.states.move_cursor_left();
395                }
396                Cmd::Move(Direction::Right) => {
397                    self.states.move_cursor_right(self.max_dataset_len());
398                }
399                Cmd::GoTo(Position::Begin) => {
400                    self.states.reset_cursor();
401                }
402                Cmd::GoTo(Position::End) => {
403                    self.states.cursor_at_end(self.max_dataset_len());
404                }
405                _ => {}
406            }
407        }
408        CmdResult::None
409    }
410
411    fn state(&self) -> State {
412        State::None
413    }
414}
415
416#[cfg(test)]
417mod test {
418
419    use super::*;
420
421    use pretty_assertions::assert_eq;
422    use tuirealm::ratatui::{symbols::Marker, widgets::GraphType};
423
424    #[test]
425    fn test_components_chart_states() {
426        let mut states: ChartStates = ChartStates::default();
427        assert_eq!(states.cursor, 0);
428        // Incr
429        states.move_cursor_right(2);
430        assert_eq!(states.cursor, 1);
431        // At end
432        states.move_cursor_right(2);
433        assert_eq!(states.cursor, 1);
434        // Decr
435        states.move_cursor_left();
436        assert_eq!(states.cursor, 0);
437        // At begin
438        states.move_cursor_left();
439        assert_eq!(states.cursor, 0);
440        // Move at end
441        states.cursor_at_end(3);
442        assert_eq!(states.cursor, 2);
443        states.reset_cursor();
444        assert_eq!(states.cursor, 0);
445    }
446
447    #[test]
448    fn test_components_chart() {
449        let mut component: Chart = Chart::default()
450            .disabled(false)
451            .background(Color::Reset)
452            .foreground(Color::Reset)
453            .borders(Borders::default())
454            .title("average temperatures in Udine", Alignment::Center)
455            .x_bounds((0.0, 11.0))
456            .x_labels(&[
457                "january",
458                "february",
459                "march",
460                "april",
461                "may",
462                "june",
463                "july",
464                "august",
465                "september",
466                "october",
467                "november",
468                "december",
469            ])
470            .x_style(Style::default().fg(Color::LightBlue))
471            .x_title("Temperature (°C)")
472            .y_bounds((-5.0, 35.0))
473            .y_labels(&["-5", "0", "5", "10", "15", "20", "25", "30", "35"])
474            .y_style(Style::default().fg(Color::LightYellow))
475            .y_title("Month")
476            .data([
477                Dataset::default()
478                    .name("Minimum")
479                    .graph_type(GraphType::Scatter)
480                    .marker(Marker::Braille)
481                    .style(Style::default().fg(Color::Cyan))
482                    .data(vec![
483                        (0.0, -1.0),
484                        (1.0, 1.0),
485                        (2.0, 3.0),
486                        (3.0, 7.0),
487                        (4.0, 11.0),
488                        (5.0, 15.0),
489                        (6.0, 17.0),
490                        (7.0, 17.0),
491                        (8.0, 13.0),
492                        (9.0, 9.0),
493                        (10.0, 4.0),
494                        (11.0, 0.0),
495                    ]),
496                Dataset::default()
497                    .name("Maximum")
498                    .graph_type(GraphType::Line)
499                    .marker(Marker::Dot)
500                    .style(Style::default().fg(Color::LightRed))
501                    .data(vec![
502                        (0.0, 7.0),
503                        (1.0, 9.0),
504                        (2.0, 13.0),
505                        (3.0, 17.0),
506                        (4.0, 22.0),
507                        (5.0, 25.0),
508                        (6.0, 28.0),
509                        (7.0, 28.0),
510                        (8.0, 24.0),
511                        (9.0, 19.0),
512                        (10.0, 13.0),
513                        (11.0, 8.0),
514                    ]),
515            ]);
516        // Commands
517        assert_eq!(component.state(), State::None);
518        // -> Right
519        assert_eq!(
520            component.perform(Cmd::Move(Direction::Right)),
521            CmdResult::None
522        );
523        assert_eq!(component.states.cursor, 1);
524        // <- Left
525        assert_eq!(
526            component.perform(Cmd::Move(Direction::Left)),
527            CmdResult::None
528        );
529        assert_eq!(component.states.cursor, 0);
530        // End
531        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
532        assert_eq!(component.states.cursor, 11);
533        // Home
534        assert_eq!(
535            component.perform(Cmd::GoTo(Position::Begin)),
536            CmdResult::None
537        );
538        assert_eq!(component.states.cursor, 0);
539        // component funcs
540        assert_eq!(component.max_dataset_len(), 12);
541        assert_eq!(component.is_disabled(), false);
542        assert_eq!(component.get_data(2).len(), 2);
543
544        let mut comp = Chart::default().data([Dataset::default()
545            .name("Maximum")
546            .graph_type(GraphType::Line)
547            .marker(Marker::Dot)
548            .style(Style::default().fg(Color::LightRed))
549            .data(vec![(0.0, 7.0)])]);
550        assert!(!comp.get_data(0).is_empty());
551
552        // Update and test empty data
553        component.states.cursor_at_end(12);
554        component.attr(
555            Attribute::Dataset,
556            AttrValue::Payload(PropPayload::Vec(vec![])),
557        );
558        assert_eq!(component.max_dataset_len(), 0);
559        // Cursor is reset
560        assert_eq!(component.states.cursor, 0);
561    }
562}