tui/widgets/
chart.rs

1use std::{borrow::Cow, cmp::max};
2
3use unicode_width::UnicodeWidthStr;
4
5use crate::layout::Alignment;
6use crate::{
7    buffer::Buffer,
8    layout::{Constraint, Rect},
9    style::{Color, Style},
10    symbols,
11    text::{Span, Spans},
12    widgets::{
13        canvas::{Canvas, Line, Points},
14        Block, Borders, Widget,
15    },
16};
17
18/// An X or Y axis for the chart widget
19#[derive(Debug, Clone)]
20pub struct Axis<'a> {
21    /// Title displayed next to axis end
22    title: Option<Spans<'a>>,
23    /// Bounds for the axis (all data points outside these limits will not be represented)
24    bounds: [f64; 2],
25    /// A list of labels to put to the left or below the axis
26    labels: Option<Vec<Span<'a>>>,
27    /// The style used to draw the axis itself
28    style: Style,
29    /// The alignment of the labels of the Axis
30    labels_alignment: Alignment,
31}
32
33impl<'a> Default for Axis<'a> {
34    fn default() -> Axis<'a> {
35        Axis {
36            title: None,
37            bounds: [0.0, 0.0],
38            labels: None,
39            style: Default::default(),
40            labels_alignment: Alignment::Left,
41        }
42    }
43}
44
45impl<'a> Axis<'a> {
46    pub fn title<T>(mut self, title: T) -> Axis<'a>
47    where
48        T: Into<Spans<'a>>,
49    {
50        self.title = Some(title.into());
51        self
52    }
53
54    #[deprecated(
55        since = "0.10.0",
56        note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title."
57    )]
58    pub fn title_style(mut self, style: Style) -> Axis<'a> {
59        if let Some(t) = self.title {
60            let title = String::from(t);
61            self.title = Some(Spans::from(Span::styled(title, style)));
62        }
63        self
64    }
65
66    pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> {
67        self.bounds = bounds;
68        self
69    }
70
71    pub fn labels(mut self, labels: Vec<Span<'a>>) -> Axis<'a> {
72        self.labels = Some(labels);
73        self
74    }
75
76    pub fn style(mut self, style: Style) -> Axis<'a> {
77        self.style = style;
78        self
79    }
80
81    /// Defines the alignment of the labels of the axis.
82    /// The alignment behaves differently based on the axis:
83    /// - Y-Axis: The labels are aligned within the area on the left of the axis
84    /// - X-Axis: The first X-axis label is aligned relative to the Y-axis
85    pub fn labels_alignment(mut self, alignment: Alignment) -> Axis<'a> {
86        self.labels_alignment = alignment;
87        self
88    }
89}
90
91/// Used to determine which style of graphing to use
92#[derive(Debug, Clone, Copy)]
93pub enum GraphType {
94    /// Draw each point
95    Scatter,
96    /// Draw each point and lines between each point using the same marker
97    Line,
98}
99
100/// A group of data points
101#[derive(Debug, Clone)]
102pub struct Dataset<'a> {
103    /// Name of the dataset (used in the legend if shown)
104    name: Cow<'a, str>,
105    /// A reference to the actual data
106    data: &'a [(f64, f64)],
107    /// Symbol used for each points of this dataset
108    marker: symbols::Marker,
109    /// Determines graph type used for drawing points
110    graph_type: GraphType,
111    /// Style used to plot this dataset
112    style: Style,
113}
114
115impl<'a> Default for Dataset<'a> {
116    fn default() -> Dataset<'a> {
117        Dataset {
118            name: Cow::from(""),
119            data: &[],
120            marker: symbols::Marker::Dot,
121            graph_type: GraphType::Scatter,
122            style: Style::default(),
123        }
124    }
125}
126
127impl<'a> Dataset<'a> {
128    pub fn name<S>(mut self, name: S) -> Dataset<'a>
129    where
130        S: Into<Cow<'a, str>>,
131    {
132        self.name = name.into();
133        self
134    }
135
136    pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> {
137        self.data = data;
138        self
139    }
140
141    pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> {
142        self.marker = marker;
143        self
144    }
145
146    pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> {
147        self.graph_type = graph_type;
148        self
149    }
150
151    pub fn style(mut self, style: Style) -> Dataset<'a> {
152        self.style = style;
153        self
154    }
155}
156
157/// A container that holds all the infos about where to display each elements of the chart (axis,
158/// labels, legend, ...).
159#[derive(Debug, Clone, PartialEq, Default)]
160struct ChartLayout {
161    /// Location of the title of the x axis
162    title_x: Option<(u16, u16)>,
163    /// Location of the title of the y axis
164    title_y: Option<(u16, u16)>,
165    /// Location of the first label of the x axis
166    label_x: Option<u16>,
167    /// Location of the first label of the y axis
168    label_y: Option<u16>,
169    /// Y coordinate of the horizontal axis
170    axis_x: Option<u16>,
171    /// X coordinate of the vertical axis
172    axis_y: Option<u16>,
173    /// Area of the legend
174    legend_area: Option<Rect>,
175    /// Area of the graph
176    graph_area: Rect,
177}
178
179/// A widget to plot one or more dataset in a cartesian coordinate system
180///
181/// # Examples
182///
183/// ```
184/// # use tui::symbols;
185/// # use tui::widgets::{Block, Borders, Chart, Axis, Dataset, GraphType};
186/// # use tui::style::{Style, Color};
187/// # use tui::text::Span;
188/// let datasets = vec![
189///     Dataset::default()
190///         .name("data1")
191///         .marker(symbols::Marker::Dot)
192///         .graph_type(GraphType::Scatter)
193///         .style(Style::default().fg(Color::Cyan))
194///         .data(&[(0.0, 5.0), (1.0, 6.0), (1.5, 6.434)]),
195///     Dataset::default()
196///         .name("data2")
197///         .marker(symbols::Marker::Braille)
198///         .graph_type(GraphType::Line)
199///         .style(Style::default().fg(Color::Magenta))
200///         .data(&[(4.0, 5.0), (5.0, 8.0), (7.66, 13.5)]),
201/// ];
202/// Chart::new(datasets)
203///     .block(Block::default().title("Chart"))
204///     .x_axis(Axis::default()
205///         .title(Span::styled("X Axis", Style::default().fg(Color::Red)))
206///         .style(Style::default().fg(Color::White))
207///         .bounds([0.0, 10.0])
208///         .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()))
209///     .y_axis(Axis::default()
210///         .title(Span::styled("Y Axis", Style::default().fg(Color::Red)))
211///         .style(Style::default().fg(Color::White))
212///         .bounds([0.0, 10.0])
213///         .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()));
214/// ```
215#[derive(Debug, Clone)]
216pub struct Chart<'a> {
217    /// A block to display around the widget eventually
218    block: Option<Block<'a>>,
219    /// The horizontal axis
220    x_axis: Axis<'a>,
221    /// The vertical axis
222    y_axis: Axis<'a>,
223    /// A reference to the datasets
224    datasets: Vec<Dataset<'a>>,
225    /// The widget base style
226    style: Style,
227    /// Constraints used to determine whether the legend should be shown or not
228    hidden_legend_constraints: (Constraint, Constraint),
229}
230
231impl<'a> Chart<'a> {
232    pub fn new(datasets: Vec<Dataset<'a>>) -> Chart<'a> {
233        Chart {
234            block: None,
235            x_axis: Axis::default(),
236            y_axis: Axis::default(),
237            style: Default::default(),
238            datasets,
239            hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
240        }
241    }
242
243    pub fn block(mut self, block: Block<'a>) -> Chart<'a> {
244        self.block = Some(block);
245        self
246    }
247
248    pub fn style(mut self, style: Style) -> Chart<'a> {
249        self.style = style;
250        self
251    }
252
253    pub fn x_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
254        self.x_axis = axis;
255        self
256    }
257
258    pub fn y_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
259        self.y_axis = axis;
260        self
261    }
262
263    /// Set the constraints used to determine whether the legend should be shown or not.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// # use tui::widgets::Chart;
269    /// # use tui::layout::Constraint;
270    /// let constraints = (
271    ///     Constraint::Ratio(1, 3),
272    ///     Constraint::Ratio(1, 4)
273    /// );
274    /// // Hide the legend when either its width is greater than 33% of the total widget width
275    /// // or if its height is greater than 25% of the total widget height.
276    /// let _chart: Chart = Chart::new(vec![])
277    ///     .hidden_legend_constraints(constraints);
278    /// ```
279    pub fn hidden_legend_constraints(mut self, constraints: (Constraint, Constraint)) -> Chart<'a> {
280        self.hidden_legend_constraints = constraints;
281        self
282    }
283
284    /// Compute the internal layout of the chart given the area. If the area is too small some
285    /// elements may be automatically hidden
286    fn layout(&self, area: Rect) -> ChartLayout {
287        let mut layout = ChartLayout::default();
288        if area.height == 0 || area.width == 0 {
289            return layout;
290        }
291        let mut x = area.left();
292        let mut y = area.bottom() - 1;
293
294        if self.x_axis.labels.is_some() && y > area.top() {
295            layout.label_x = Some(y);
296            y -= 1;
297        }
298
299        layout.label_y = self.y_axis.labels.as_ref().and(Some(x));
300        x += self.max_width_of_labels_left_of_y_axis(area, self.y_axis.labels.is_some());
301
302        if self.x_axis.labels.is_some() && y > area.top() {
303            layout.axis_x = Some(y);
304            y -= 1;
305        }
306
307        if self.y_axis.labels.is_some() && x + 1 < area.right() {
308            layout.axis_y = Some(x);
309            x += 1;
310        }
311
312        if x < area.right() && y > 1 {
313            layout.graph_area = Rect::new(x, area.top(), area.right() - x, y - area.top() + 1);
314        }
315
316        if let Some(ref title) = self.x_axis.title {
317            let w = title.width() as u16;
318            if w < layout.graph_area.width && layout.graph_area.height > 2 {
319                layout.title_x = Some((x + layout.graph_area.width - w, y));
320            }
321        }
322
323        if let Some(ref title) = self.y_axis.title {
324            let w = title.width() as u16;
325            if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 {
326                layout.title_y = Some((x, area.top()));
327            }
328        }
329
330        if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
331            let legend_width = inner_width + 2;
332            let legend_height = self.datasets.len() as u16 + 2;
333            let max_legend_width = self
334                .hidden_legend_constraints
335                .0
336                .apply(layout.graph_area.width);
337            let max_legend_height = self
338                .hidden_legend_constraints
339                .1
340                .apply(layout.graph_area.height);
341            if inner_width > 0
342                && legend_width < max_legend_width
343                && legend_height < max_legend_height
344            {
345                layout.legend_area = Some(Rect::new(
346                    layout.graph_area.right() - legend_width,
347                    layout.graph_area.top(),
348                    legend_width,
349                    legend_height,
350                ));
351            }
352        }
353        layout
354    }
355
356    fn max_width_of_labels_left_of_y_axis(&self, area: Rect, has_y_axis: bool) -> u16 {
357        let mut max_width = self
358            .y_axis
359            .labels
360            .as_ref()
361            .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16)
362            .unwrap_or_default();
363
364        if let Some(first_x_label) = self.x_axis.labels.as_ref().and_then(|labels| labels.get(0)) {
365            let first_label_width = first_x_label.content.width() as u16;
366            let width_left_of_y_axis = match self.x_axis.labels_alignment {
367                Alignment::Left => {
368                    // The last character of the label should be below the Y-Axis when it exists, not on its left
369                    let y_axis_offset = if has_y_axis { 1 } else { 0 };
370                    first_label_width.saturating_sub(y_axis_offset)
371                }
372                Alignment::Center => first_label_width / 2,
373                Alignment::Right => 0,
374            };
375            max_width = max(max_width, width_left_of_y_axis);
376        }
377        // labels of y axis and first label of x axis can take at most 1/3rd of the total width
378        max_width.min(area.width / 3)
379    }
380
381    fn render_x_labels(
382        &mut self,
383        buf: &mut Buffer,
384        layout: &ChartLayout,
385        chart_area: Rect,
386        graph_area: Rect,
387    ) {
388        let y = match layout.label_x {
389            Some(y) => y,
390            None => return,
391        };
392        let labels = self.x_axis.labels.as_ref().unwrap();
393        let labels_len = labels.len() as u16;
394        if labels_len < 2 {
395            return;
396        }
397
398        let width_between_ticks = graph_area.width / labels_len;
399
400        let label_area = self.first_x_label_area(
401            y,
402            labels.first().unwrap().width() as u16,
403            width_between_ticks,
404            chart_area,
405            graph_area,
406        );
407
408        let label_alignment = match self.x_axis.labels_alignment {
409            Alignment::Left => Alignment::Right,
410            Alignment::Center => Alignment::Center,
411            Alignment::Right => Alignment::Left,
412        };
413
414        Self::render_label(buf, labels.first().unwrap(), label_area, label_alignment);
415
416        for (i, label) in labels[1..labels.len() - 1].iter().enumerate() {
417            // We add 1 to x (and width-1 below) to leave at least one space before each intermediate labels
418            let x = graph_area.left() + (i + 1) as u16 * width_between_ticks + 1;
419            let label_area = Rect::new(x, y, width_between_ticks.saturating_sub(1), 1);
420
421            Self::render_label(buf, label, label_area, Alignment::Center);
422        }
423
424        let x = graph_area.right() - width_between_ticks;
425        let label_area = Rect::new(x, y, width_between_ticks, 1);
426        // The last label should be aligned Right to be at the edge of the graph area
427        Self::render_label(buf, labels.last().unwrap(), label_area, Alignment::Right);
428    }
429
430    fn first_x_label_area(
431        &self,
432        y: u16,
433        label_width: u16,
434        max_width_after_y_axis: u16,
435        chart_area: Rect,
436        graph_area: Rect,
437    ) -> Rect {
438        let (min_x, max_x) = match self.x_axis.labels_alignment {
439            Alignment::Left => (chart_area.left(), graph_area.left()),
440            Alignment::Center => (
441                chart_area.left(),
442                graph_area.left() + max_width_after_y_axis.min(label_width),
443            ),
444            Alignment::Right => (
445                graph_area.left().saturating_sub(1),
446                graph_area.left() + max_width_after_y_axis,
447            ),
448        };
449
450        Rect::new(min_x, y, max_x - min_x, 1)
451    }
452
453    fn render_label(buf: &mut Buffer, label: &Span, label_area: Rect, alignment: Alignment) {
454        let label_width = label.width() as u16;
455        let bounded_label_width = label_area.width.min(label_width);
456
457        let x = match alignment {
458            Alignment::Left => label_area.left(),
459            Alignment::Center => label_area.left() + label_area.width / 2 - bounded_label_width / 2,
460            Alignment::Right => label_area.right() - bounded_label_width,
461        };
462
463        buf.set_span(x, label_area.top(), label, bounded_label_width);
464    }
465
466    fn render_y_labels(
467        &mut self,
468        buf: &mut Buffer,
469        layout: &ChartLayout,
470        chart_area: Rect,
471        graph_area: Rect,
472    ) {
473        let x = match layout.label_y {
474            Some(x) => x,
475            None => return,
476        };
477        let labels = self.y_axis.labels.as_ref().unwrap();
478        let labels_len = labels.len() as u16;
479        for (i, label) in labels.iter().enumerate() {
480            let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
481            if dy < graph_area.bottom() {
482                let label_area = Rect::new(
483                    x,
484                    graph_area.bottom().saturating_sub(1) - dy,
485                    (graph_area.left() - chart_area.left()).saturating_sub(1),
486                    1,
487                );
488                Self::render_label(buf, label, label_area, self.y_axis.labels_alignment);
489            }
490        }
491    }
492}
493
494impl<'a> Widget for Chart<'a> {
495    fn render(mut self, area: Rect, buf: &mut Buffer) {
496        if area.area() == 0 {
497            return;
498        }
499        buf.set_style(area, self.style);
500        // Sample the style of the entire widget. This sample will be used to reset the style of
501        // the cells that are part of the components put on top of the grah area (i.e legend and
502        // axis names).
503        let original_style = buf.get(area.left(), area.top()).style();
504
505        let chart_area = match self.block.take() {
506            Some(b) => {
507                let inner_area = b.inner(area);
508                b.render(area, buf);
509                inner_area
510            }
511            None => area,
512        };
513
514        let layout = self.layout(chart_area);
515        let graph_area = layout.graph_area;
516        if graph_area.width < 1 || graph_area.height < 1 {
517            return;
518        }
519
520        self.render_x_labels(buf, &layout, chart_area, graph_area);
521        self.render_y_labels(buf, &layout, chart_area, graph_area);
522
523        if let Some(y) = layout.axis_x {
524            for x in graph_area.left()..graph_area.right() {
525                buf.get_mut(x, y)
526                    .set_symbol(symbols::line::HORIZONTAL)
527                    .set_style(self.x_axis.style);
528            }
529        }
530
531        if let Some(x) = layout.axis_y {
532            for y in graph_area.top()..graph_area.bottom() {
533                buf.get_mut(x, y)
534                    .set_symbol(symbols::line::VERTICAL)
535                    .set_style(self.y_axis.style);
536            }
537        }
538
539        if let Some(y) = layout.axis_x {
540            if let Some(x) = layout.axis_y {
541                buf.get_mut(x, y)
542                    .set_symbol(symbols::line::BOTTOM_LEFT)
543                    .set_style(self.x_axis.style);
544            }
545        }
546
547        for dataset in &self.datasets {
548            Canvas::default()
549                .background_color(self.style.bg.unwrap_or(Color::Reset))
550                .x_bounds(self.x_axis.bounds)
551                .y_bounds(self.y_axis.bounds)
552                .marker(dataset.marker)
553                .paint(|ctx| {
554                    ctx.draw(&Points {
555                        coords: dataset.data,
556                        color: dataset.style.fg.unwrap_or(Color::Reset),
557                    });
558                    if let GraphType::Line = dataset.graph_type {
559                        for data in dataset.data.windows(2) {
560                            ctx.draw(&Line {
561                                x1: data[0].0,
562                                y1: data[0].1,
563                                x2: data[1].0,
564                                y2: data[1].1,
565                                color: dataset.style.fg.unwrap_or(Color::Reset),
566                            })
567                        }
568                    }
569                })
570                .render(graph_area, buf);
571        }
572
573        if let Some(legend_area) = layout.legend_area {
574            buf.set_style(legend_area, original_style);
575            Block::default()
576                .borders(Borders::ALL)
577                .render(legend_area, buf);
578            for (i, dataset) in self.datasets.iter().enumerate() {
579                buf.set_string(
580                    legend_area.x + 1,
581                    legend_area.y + 1 + i as u16,
582                    &dataset.name,
583                    dataset.style,
584                );
585            }
586        }
587
588        if let Some((x, y)) = layout.title_x {
589            let title = self.x_axis.title.unwrap();
590            let width = graph_area.right().saturating_sub(x);
591            buf.set_style(
592                Rect {
593                    x,
594                    y,
595                    width,
596                    height: 1,
597                },
598                original_style,
599            );
600            buf.set_spans(x, y, &title, width);
601        }
602
603        if let Some((x, y)) = layout.title_y {
604            let title = self.y_axis.title.unwrap();
605            let width = graph_area.right().saturating_sub(x);
606            buf.set_style(
607                Rect {
608                    x,
609                    y,
610                    width,
611                    height: 1,
612                },
613                original_style,
614            );
615            buf.set_spans(x, y, &title, width);
616        }
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    struct LegendTestCase {
625        chart_area: Rect,
626        hidden_legend_constraints: (Constraint, Constraint),
627        legend_area: Option<Rect>,
628    }
629
630    #[test]
631    fn it_should_hide_the_legend() {
632        let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)];
633        let cases = [
634            LegendTestCase {
635                chart_area: Rect::new(0, 0, 100, 100),
636                hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
637                legend_area: Some(Rect::new(88, 0, 12, 12)),
638            },
639            LegendTestCase {
640                chart_area: Rect::new(0, 0, 100, 100),
641                hidden_legend_constraints: (Constraint::Ratio(1, 10), Constraint::Ratio(1, 4)),
642                legend_area: None,
643            },
644        ];
645        for case in &cases {
646            let datasets = (0..10)
647                .map(|i| {
648                    let name = format!("Dataset #{}", i);
649                    Dataset::default().name(name).data(&data)
650                })
651                .collect::<Vec<_>>();
652            let chart = Chart::new(datasets)
653                .x_axis(Axis::default().title("X axis"))
654                .y_axis(Axis::default().title("Y axis"))
655                .hidden_legend_constraints(case.hidden_legend_constraints);
656            let layout = chart.layout(case.chart_area);
657            assert_eq!(layout.legend_area, case.legend_area);
658        }
659    }
660}