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)]
86pub struct Chart {
87    props: Props,
88    pub states: ChartStates,
89}
90
91impl Chart {
92    pub fn foreground(mut self, fg: Color) -> Self {
93        self.props.set(Attribute::Foreground, AttrValue::Color(fg));
94        self
95    }
96
97    pub fn background(mut self, bg: Color) -> Self {
98        self.props.set(Attribute::Background, AttrValue::Color(bg));
99        self
100    }
101
102    pub fn borders(mut self, b: Borders) -> Self {
103        self.props.set(Attribute::Borders, AttrValue::Borders(b));
104        self
105    }
106
107    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
108        self.props
109            .set(Attribute::Title, AttrValue::Title((t.into(), a)));
110        self
111    }
112
113    pub fn disabled(mut self, disabled: bool) -> Self {
114        self.attr(Attribute::Disabled, AttrValue::Flag(disabled));
115        self
116    }
117
118    pub fn inactive(mut self, s: Style) -> Self {
119        self.props.set(Attribute::FocusStyle, AttrValue::Style(s));
120        self
121    }
122
123    pub fn data(mut self, data: &[Dataset]) -> Self {
124        self.props.set(
125            Attribute::Dataset,
126            AttrValue::Payload(PropPayload::Vec(
127                data.iter().cloned().map(PropValue::Dataset).collect(),
128            )),
129        );
130        self
131    }
132
133    pub fn x_bounds(mut self, bounds: (f64, f64)) -> Self {
134        self.props.set(
135            Attribute::Custom(CHART_X_BOUNDS),
136            AttrValue::Payload(PropPayload::Tup2((
137                PropValue::F64(bounds.0),
138                PropValue::F64(bounds.1),
139            ))),
140        );
141        self
142    }
143
144    pub fn y_bounds(mut self, bounds: (f64, f64)) -> Self {
145        self.props.set(
146            Attribute::Custom(CHART_Y_BOUNDS),
147            AttrValue::Payload(PropPayload::Tup2((
148                PropValue::F64(bounds.0),
149                PropValue::F64(bounds.1),
150            ))),
151        );
152        self
153    }
154
155    pub fn x_labels(mut self, labels: &[&str]) -> Self {
156        self.attr(
157            Attribute::Custom(CHART_X_LABELS),
158            AttrValue::Payload(PropPayload::Vec(
159                labels
160                    .iter()
161                    .map(|x| PropValue::Str(x.to_string()))
162                    .collect(),
163            )),
164        );
165        self
166    }
167
168    pub fn y_labels(mut self, labels: &[&str]) -> Self {
169        self.attr(
170            Attribute::Custom(CHART_Y_LABELS),
171            AttrValue::Payload(PropPayload::Vec(
172                labels
173                    .iter()
174                    .map(|x| PropValue::Str(x.to_string()))
175                    .collect(),
176            )),
177        );
178        self
179    }
180
181    pub fn x_style(mut self, s: Style) -> Self {
182        self.attr(Attribute::Custom(CHART_X_STYLE), AttrValue::Style(s));
183        self
184    }
185
186    pub fn y_style(mut self, s: Style) -> Self {
187        self.attr(Attribute::Custom(CHART_Y_STYLE), AttrValue::Style(s));
188        self
189    }
190
191    pub fn x_title<S: Into<String>>(mut self, t: S) -> Self {
192        self.props.set(
193            Attribute::Custom(CHART_X_TITLE),
194            AttrValue::String(t.into()),
195        );
196        self
197    }
198
199    pub fn y_title<S: Into<String>>(mut self, t: S) -> Self {
200        self.props.set(
201            Attribute::Custom(CHART_Y_TITLE),
202            AttrValue::String(t.into()),
203        );
204        self
205    }
206
207    fn is_disabled(&self) -> bool {
208        self.props
209            .get_or(Attribute::Disabled, AttrValue::Flag(false))
210            .unwrap_flag()
211    }
212
213    /// ### max_dataset_len
214    ///
215    /// Get the maximum len among the datasets
216    fn max_dataset_len(&self) -> usize {
217        self.props
218            .get(Attribute::Dataset)
219            .map(|x| {
220                x.unwrap_payload()
221                    .unwrap_vec()
222                    .iter()
223                    .cloned()
224                    .map(|x| x.unwrap_dataset().get_data().len())
225                    .max()
226            })
227            .unwrap_or(None)
228            .unwrap_or(0)
229    }
230
231    /// ### data
232    ///
233    /// Get data to be displayed, starting from provided index at `start` with a max length of `len`
234    fn get_data(&mut self, start: usize, len: 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, len))
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 `len` are preserved from dataset
259    fn get_tui_dataset(dataset: &'a Dataset, start: usize, len: usize) -> TuiDataset<'a> {
260        // Recalc len
261        let points = dataset.get_data();
262        let end: usize = match points.len() > start {
263            true => std::cmp::min(len, points.len() - start),
264            false => 0,
265        };
266
267        // Prepare data storage
268        TuiDataset::default()
269            .name(dataset.name.clone())
270            .marker(dataset.marker)
271            .graph_type(dataset.graph_type)
272            .style(dataset.style)
273            .data(&points[start..end])
274    }
275}
276
277impl MockComponent for Chart {
278    fn view(&mut self, render: &mut Frame, area: Rect) {
279        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
280            let foreground = self
281                .props
282                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
283                .unwrap_color();
284            let background = self
285                .props
286                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
287                .unwrap_color();
288            let borders = self
289                .props
290                .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
291                .unwrap_borders();
292            let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title());
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 = match self.is_disabled() {
302                true => true,
303                false => focus,
304            };
305            let div = crate::utils::get_block(borders, title, active, inactive_style);
306            // Create widget
307            // -- x axis
308            let mut x_axis: Axis = Axis::default();
309            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
310                .props
311                .get(Attribute::Custom(CHART_X_BOUNDS))
312                .map(|x| x.unwrap_payload().unwrap_tup2())
313            {
314                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
315                x_axis = x_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
316            }
317            if let Some(PropPayload::Vec(labels)) = self
318                .props
319                .get(Attribute::Custom(CHART_X_LABELS))
320                .map(|x| x.unwrap_payload())
321            {
322                x_axis = x_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
323            }
324            if let Some(s) = self
325                .props
326                .get(Attribute::Custom(CHART_X_STYLE))
327                .map(|x| x.unwrap_style())
328            {
329                x_axis = x_axis.style(s);
330            }
331            if let Some(title) = self
332                .props
333                .get(Attribute::Custom(CHART_X_TITLE))
334                .map(|x| x.unwrap_string())
335            {
336                x_axis = x_axis.title(Span::styled(
337                    title,
338                    Style::default().fg(foreground).bg(background),
339                ));
340            }
341            // -- y axis
342            let mut y_axis: Axis = Axis::default();
343            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
344                .props
345                .get(Attribute::Custom(CHART_Y_BOUNDS))
346                .map(|x| x.unwrap_payload().unwrap_tup2())
347            {
348                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
349                y_axis = y_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
350            }
351            if let Some(PropPayload::Vec(labels)) = self
352                .props
353                .get(Attribute::Custom(CHART_Y_LABELS))
354                .map(|x| x.unwrap_payload())
355            {
356                y_axis = y_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
357            }
358            if let Some(s) = self
359                .props
360                .get(Attribute::Custom(CHART_Y_STYLE))
361                .map(|x| x.unwrap_style())
362            {
363                y_axis = y_axis.style(s);
364            }
365            if let Some(title) = self
366                .props
367                .get(Attribute::Custom(CHART_Y_TITLE))
368                .map(|x| x.unwrap_string())
369            {
370                y_axis = y_axis.title(Span::styled(
371                    title,
372                    Style::default().fg(foreground).bg(background),
373                ));
374            }
375            // Get data
376            let data: Vec<TuiDataset> = self.get_data(self.states.cursor, area.width as usize);
377            // Build widget
378            let widget: TuiChart = TuiChart::new(data).block(div).x_axis(x_axis).y_axis(y_axis);
379            // Render
380            render.render_widget(widget, area);
381        }
382    }
383
384    fn query(&self, attr: Attribute) -> Option<AttrValue> {
385        self.props.get(attr)
386    }
387
388    fn attr(&mut self, attr: Attribute, value: AttrValue) {
389        self.props.set(attr, value);
390        self.states.reset_cursor();
391    }
392
393    fn perform(&mut self, cmd: Cmd) -> CmdResult {
394        if !self.is_disabled() {
395            match cmd {
396                Cmd::Move(Direction::Left) => {
397                    self.states.move_cursor_left();
398                }
399                Cmd::Move(Direction::Right) => {
400                    self.states.move_cursor_right(self.max_dataset_len());
401                }
402                Cmd::GoTo(Position::Begin) => {
403                    self.states.reset_cursor();
404                }
405                Cmd::GoTo(Position::End) => {
406                    self.states.cursor_at_end(self.max_dataset_len());
407                }
408                _ => {}
409            }
410        }
411        CmdResult::None
412    }
413
414    fn state(&self) -> State {
415        State::None
416    }
417}
418
419#[cfg(test)]
420mod test {
421
422    use super::*;
423
424    use pretty_assertions::assert_eq;
425    use tuirealm::ratatui::{symbols::Marker, widgets::GraphType};
426
427    #[test]
428    fn test_components_chart_states() {
429        let mut states: ChartStates = ChartStates::default();
430        assert_eq!(states.cursor, 0);
431        // Incr
432        states.move_cursor_right(2);
433        assert_eq!(states.cursor, 1);
434        // At end
435        states.move_cursor_right(2);
436        assert_eq!(states.cursor, 1);
437        // Decr
438        states.move_cursor_left();
439        assert_eq!(states.cursor, 0);
440        // At begin
441        states.move_cursor_left();
442        assert_eq!(states.cursor, 0);
443        // Move at end
444        states.cursor_at_end(3);
445        assert_eq!(states.cursor, 2);
446        states.reset_cursor();
447        assert_eq!(states.cursor, 0);
448    }
449
450    #[test]
451    fn test_components_chart() {
452        let mut component: Chart = Chart::default()
453            .disabled(false)
454            .background(Color::Reset)
455            .foreground(Color::Reset)
456            .borders(Borders::default())
457            .title("average temperatures in Udine", Alignment::Center)
458            .x_bounds((0.0, 11.0))
459            .x_labels(&[
460                "january",
461                "february",
462                "march",
463                "april",
464                "may",
465                "june",
466                "july",
467                "august",
468                "september",
469                "october",
470                "november",
471                "december",
472            ])
473            .x_style(Style::default().fg(Color::LightBlue))
474            .x_title("Temperature (°C)")
475            .y_bounds((-5.0, 35.0))
476            .y_labels(&["-5", "0", "5", "10", "15", "20", "25", "30", "35"])
477            .y_style(Style::default().fg(Color::LightYellow))
478            .y_title("Month")
479            .data(&[
480                Dataset::default()
481                    .name("Minimum")
482                    .graph_type(GraphType::Scatter)
483                    .marker(Marker::Braille)
484                    .style(Style::default().fg(Color::Cyan))
485                    .data(vec![
486                        (0.0, -1.0),
487                        (1.0, 1.0),
488                        (2.0, 3.0),
489                        (3.0, 7.0),
490                        (4.0, 11.0),
491                        (5.0, 15.0),
492                        (6.0, 17.0),
493                        (7.0, 17.0),
494                        (8.0, 13.0),
495                        (9.0, 9.0),
496                        (10.0, 4.0),
497                        (11.0, 0.0),
498                    ]),
499                Dataset::default()
500                    .name("Maximum")
501                    .graph_type(GraphType::Line)
502                    .marker(Marker::Dot)
503                    .style(Style::default().fg(Color::LightRed))
504                    .data(vec![
505                        (0.0, 7.0),
506                        (1.0, 9.0),
507                        (2.0, 13.0),
508                        (3.0, 17.0),
509                        (4.0, 22.0),
510                        (5.0, 25.0),
511                        (6.0, 28.0),
512                        (7.0, 28.0),
513                        (8.0, 24.0),
514                        (9.0, 19.0),
515                        (10.0, 13.0),
516                        (11.0, 8.0),
517                    ]),
518            ]);
519        // Commands
520        assert_eq!(component.state(), State::None);
521        // -> Right
522        assert_eq!(
523            component.perform(Cmd::Move(Direction::Right)),
524            CmdResult::None
525        );
526        assert_eq!(component.states.cursor, 1);
527        // <- Left
528        assert_eq!(
529            component.perform(Cmd::Move(Direction::Left)),
530            CmdResult::None
531        );
532        assert_eq!(component.states.cursor, 0);
533        // End
534        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
535        assert_eq!(component.states.cursor, 11);
536        // Home
537        assert_eq!(
538            component.perform(Cmd::GoTo(Position::Begin)),
539            CmdResult::None
540        );
541        assert_eq!(component.states.cursor, 0);
542        // component funcs
543        assert_eq!(component.max_dataset_len(), 12);
544        assert_eq!(component.is_disabled(), false);
545        assert_eq!(component.get_data(2, 4).len(), 2);
546
547        let mut comp = Chart::default().data(&[Dataset::default()
548            .name("Maximum")
549            .graph_type(GraphType::Line)
550            .marker(Marker::Dot)
551            .style(Style::default().fg(Color::LightRed))
552            .data(vec![(0.0, 7.0)])]);
553        assert!(comp.get_data(0, 1).len() > 0);
554
555        // Update and test empty data
556        component.states.cursor_at_end(12);
557        component.attr(
558            Attribute::Dataset,
559            AttrValue::Payload(PropPayload::Vec(vec![])),
560        );
561        assert_eq!(component.max_dataset_len(), 0);
562        // Cursor is reset
563        assert_eq!(component.states.cursor, 0);
564    }
565}