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 normal_style = Style::default().fg(foreground).bg(background);
302            let active: bool = if self.is_disabled() { true } else { focus };
303            let div = crate::utils::get_block(borders, title.as_ref(), active, inactive_style);
304            // Create widget
305            // -- x axis
306            let mut x_axis: Axis = Axis::default();
307            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
308                .props
309                .get(Attribute::Custom(CHART_X_BOUNDS))
310                .map(|x| x.unwrap_payload().unwrap_tup2())
311            {
312                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
313                x_axis = x_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
314            }
315            if let Some(PropPayload::Vec(labels)) = self
316                .props
317                .get(Attribute::Custom(CHART_X_LABELS))
318                .map(|x| x.unwrap_payload())
319            {
320                x_axis = x_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
321            }
322            if let Some(s) = self
323                .props
324                .get(Attribute::Custom(CHART_X_STYLE))
325                .map(|x| x.unwrap_style())
326            {
327                x_axis = x_axis.style(s);
328            }
329            if let Some(title) = self
330                .props
331                .get(Attribute::Custom(CHART_X_TITLE))
332                .map(|x| x.unwrap_string())
333            {
334                x_axis = x_axis.title(Span::styled(title, normal_style));
335            }
336            // -- y axis
337            let mut y_axis: Axis = Axis::default();
338            if let Some((PropValue::F64(floor), PropValue::F64(ceil))) = self
339                .props
340                .get(Attribute::Custom(CHART_Y_BOUNDS))
341                .map(|x| x.unwrap_payload().unwrap_tup2())
342            {
343                let why_using_vecs_when_you_can_use_useless_arrays: [f64; 2] = [floor, ceil];
344                y_axis = y_axis.bounds(why_using_vecs_when_you_can_use_useless_arrays);
345            }
346            if let Some(PropPayload::Vec(labels)) = self
347                .props
348                .get(Attribute::Custom(CHART_Y_LABELS))
349                .map(|x| x.unwrap_payload())
350            {
351                y_axis = y_axis.labels(labels.iter().cloned().map(|x| Line::from(x.unwrap_str())));
352            }
353            if let Some(s) = self
354                .props
355                .get(Attribute::Custom(CHART_Y_STYLE))
356                .map(|x| x.unwrap_style())
357            {
358                y_axis = y_axis.style(s);
359            }
360            if let Some(title) = self
361                .props
362                .get(Attribute::Custom(CHART_Y_TITLE))
363                .map(|x| x.unwrap_string())
364            {
365                y_axis = y_axis.title(Span::styled(title, normal_style));
366            }
367            // Get data
368            let data: Vec<TuiDataset> = self.get_data(self.states.cursor);
369            // Build widget
370            let widget: TuiChart = TuiChart::new(data)
371                .style(normal_style)
372                .block(div)
373                .x_axis(x_axis)
374                .y_axis(y_axis);
375            // Render
376            render.render_widget(widget, area);
377        }
378    }
379
380    fn query(&self, attr: Attribute) -> Option<AttrValue> {
381        self.props.get(attr)
382    }
383
384    fn attr(&mut self, attr: Attribute, value: AttrValue) {
385        self.props.set(attr, value);
386        self.states.reset_cursor();
387    }
388
389    fn perform(&mut self, cmd: Cmd) -> CmdResult {
390        if !self.is_disabled() {
391            match cmd {
392                Cmd::Move(Direction::Left) => {
393                    self.states.move_cursor_left();
394                }
395                Cmd::Move(Direction::Right) => {
396                    self.states.move_cursor_right(self.max_dataset_len());
397                }
398                Cmd::GoTo(Position::Begin) => {
399                    self.states.reset_cursor();
400                }
401                Cmd::GoTo(Position::End) => {
402                    self.states.cursor_at_end(self.max_dataset_len());
403                }
404                _ => {}
405            }
406        }
407        CmdResult::None
408    }
409
410    fn state(&self) -> State {
411        State::None
412    }
413}
414
415#[cfg(test)]
416mod test {
417
418    use super::*;
419
420    use pretty_assertions::assert_eq;
421    use tuirealm::ratatui::{symbols::Marker, widgets::GraphType};
422
423    #[test]
424    fn test_components_chart_states() {
425        let mut states: ChartStates = ChartStates::default();
426        assert_eq!(states.cursor, 0);
427        // Incr
428        states.move_cursor_right(2);
429        assert_eq!(states.cursor, 1);
430        // At end
431        states.move_cursor_right(2);
432        assert_eq!(states.cursor, 1);
433        // Decr
434        states.move_cursor_left();
435        assert_eq!(states.cursor, 0);
436        // At begin
437        states.move_cursor_left();
438        assert_eq!(states.cursor, 0);
439        // Move at end
440        states.cursor_at_end(3);
441        assert_eq!(states.cursor, 2);
442        states.reset_cursor();
443        assert_eq!(states.cursor, 0);
444    }
445
446    #[test]
447    fn test_components_chart() {
448        let mut component: Chart = Chart::default()
449            .disabled(false)
450            .background(Color::Reset)
451            .foreground(Color::Reset)
452            .borders(Borders::default())
453            .title("average temperatures in Udine", Alignment::Center)
454            .x_bounds((0.0, 11.0))
455            .x_labels(&[
456                "january",
457                "february",
458                "march",
459                "april",
460                "may",
461                "june",
462                "july",
463                "august",
464                "september",
465                "october",
466                "november",
467                "december",
468            ])
469            .x_style(Style::default().fg(Color::LightBlue))
470            .x_title("Temperature (°C)")
471            .y_bounds((-5.0, 35.0))
472            .y_labels(&["-5", "0", "5", "10", "15", "20", "25", "30", "35"])
473            .y_style(Style::default().fg(Color::LightYellow))
474            .y_title("Month")
475            .data([
476                Dataset::default()
477                    .name("Minimum")
478                    .graph_type(GraphType::Scatter)
479                    .marker(Marker::Braille)
480                    .style(Style::default().fg(Color::Cyan))
481                    .data(vec![
482                        (0.0, -1.0),
483                        (1.0, 1.0),
484                        (2.0, 3.0),
485                        (3.0, 7.0),
486                        (4.0, 11.0),
487                        (5.0, 15.0),
488                        (6.0, 17.0),
489                        (7.0, 17.0),
490                        (8.0, 13.0),
491                        (9.0, 9.0),
492                        (10.0, 4.0),
493                        (11.0, 0.0),
494                    ]),
495                Dataset::default()
496                    .name("Maximum")
497                    .graph_type(GraphType::Line)
498                    .marker(Marker::Dot)
499                    .style(Style::default().fg(Color::LightRed))
500                    .data(vec![
501                        (0.0, 7.0),
502                        (1.0, 9.0),
503                        (2.0, 13.0),
504                        (3.0, 17.0),
505                        (4.0, 22.0),
506                        (5.0, 25.0),
507                        (6.0, 28.0),
508                        (7.0, 28.0),
509                        (8.0, 24.0),
510                        (9.0, 19.0),
511                        (10.0, 13.0),
512                        (11.0, 8.0),
513                    ]),
514            ]);
515        // Commands
516        assert_eq!(component.state(), State::None);
517        // -> Right
518        assert_eq!(
519            component.perform(Cmd::Move(Direction::Right)),
520            CmdResult::None
521        );
522        assert_eq!(component.states.cursor, 1);
523        // <- Left
524        assert_eq!(
525            component.perform(Cmd::Move(Direction::Left)),
526            CmdResult::None
527        );
528        assert_eq!(component.states.cursor, 0);
529        // End
530        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
531        assert_eq!(component.states.cursor, 11);
532        // Home
533        assert_eq!(
534            component.perform(Cmd::GoTo(Position::Begin)),
535            CmdResult::None
536        );
537        assert_eq!(component.states.cursor, 0);
538        // component funcs
539        assert_eq!(component.max_dataset_len(), 12);
540        assert_eq!(component.is_disabled(), false);
541        assert_eq!(component.get_data(2).len(), 2);
542
543        let mut comp = Chart::default().data([Dataset::default()
544            .name("Maximum")
545            .graph_type(GraphType::Line)
546            .marker(Marker::Dot)
547            .style(Style::default().fg(Color::LightRed))
548            .data(vec![(0.0, 7.0)])]);
549        assert!(!comp.get_data(0).is_empty());
550
551        // Update and test empty data
552        component.states.cursor_at_end(12);
553        component.attr(
554            Attribute::Dataset,
555            AttrValue::Payload(PropPayload::Vec(vec![])),
556        );
557        assert_eq!(component.max_dataset_len(), 0);
558        // Cursor is reset
559        assert_eq!(component.states.cursor, 0);
560    }
561}