Skip to main content

tui_realm_stdlib/components/chart/
chart.rs

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