diff --git a/src/widgets/chart.rs b/src/widgets/chart.rs index ee879dc6..0b2a0895 100644 --- a/src/widgets/chart.rs +++ b/src/widgets/chart.rs @@ -1,3 +1,8 @@ +use std::{borrow::Cow, cmp::max}; + +use unicode_width::UnicodeWidthStr; + +use crate::layout::Alignment; use crate::{ buffer::Buffer, layout::{Constraint, Rect}, @@ -9,8 +14,6 @@ use crate::{ Block, Borders, Widget, }, }; -use std::{borrow::Cow, cmp::max}; -use unicode_width::UnicodeWidthStr; /// An X or Y axis for the chart widget #[derive(Debug, Clone)] @@ -23,6 +26,8 @@ pub struct Axis<'a> { labels: Option>>, /// The style used to draw the axis itself style: Style, + /// The alignment of the labels of the Axis + labels_alignment: Alignment, } impl<'a> Default for Axis<'a> { @@ -32,6 +37,7 @@ impl<'a> Default for Axis<'a> { bounds: [0.0, 0.0], labels: None, style: Default::default(), + labels_alignment: Alignment::Left, } } } @@ -71,6 +77,15 @@ impl<'a> Axis<'a> { self.style = style; self } + + /// Defines the alignment of the labels of the axis. + /// The alignment behaves differently based on the axis: + /// - Y-Axis: The labels are aligned within the area on the left of the axis + /// - X-Axis: The first X-axis label is aligned relative to the Y-axis + pub fn labels_alignment(mut self, alignment: Alignment) -> Axis<'a> { + self.labels_alignment = alignment; + self + } } /// Used to determine which style of graphing to use @@ -282,7 +297,7 @@ impl<'a> Chart<'a> { } layout.label_y = self.y_axis.labels.as_ref().and(Some(x)); - x += self.max_width_of_labels_left_of_y_axis(area); + x += self.max_width_of_labels_left_of_y_axis(area, self.y_axis.labels.is_some()); if self.x_axis.labels.is_some() && y > area.top() { layout.axis_x = Some(y); @@ -338,17 +353,26 @@ impl<'a> Chart<'a> { layout } - fn max_width_of_labels_left_of_y_axis(&self, area: Rect) -> u16 { + fn max_width_of_labels_left_of_y_axis(&self, area: Rect, has_y_axis: bool) -> u16 { let mut max_width = self .y_axis .labels .as_ref() .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16) .unwrap_or_default(); - if let Some(ref x_labels) = self.x_axis.labels { - if !x_labels.is_empty() { - max_width = max(max_width, x_labels[0].content.width() as u16); - } + + if let Some(first_x_label) = self.x_axis.labels.as_ref().and_then(|labels| labels.get(0)) { + let first_label_width = first_x_label.content.width() as u16; + let width_left_of_y_axis = match self.x_axis.labels_alignment { + Alignment::Left => { + // The last character of the label should be below the Y-Axis when it exists, not on its left + let y_axis_offset = if has_y_axis { 1 } else { 0 }; + first_label_width.saturating_sub(y_axis_offset) + } + Alignment::Center => first_label_width / 2, + Alignment::Right => 0, + }; + max_width = max(max_width, width_left_of_y_axis); } // labels of y axis and first label of x axis can take at most 1/3rd of the total width max_width.min(area.width / 3) @@ -370,26 +394,73 @@ impl<'a> Chart<'a> { if labels_len < 2 { return; } - let width_between_ticks = graph_area.width / (labels_len - 1); - for (i, label) in labels.iter().enumerate() { - let label_width = label.width() as u16; - let label_width = if i == 0 { - // the first label is put between the left border of the chart and the y axis. - graph_area - .left() - .saturating_sub(chart_area.left()) - .min(label_width) - } else { - // other labels are put on the left of each tick on the x axis - width_between_ticks.min(label_width) - }; - buf.set_span( - graph_area.left() + i as u16 * width_between_ticks - label_width, - y, - label, - label_width, - ); + + let width_between_ticks = graph_area.width / labels_len; + + let label_area = self.first_x_label_area( + y, + labels.first().unwrap().width() as u16, + width_between_ticks, + chart_area, + graph_area, + ); + + let label_alignment = match self.x_axis.labels_alignment { + Alignment::Left => Alignment::Right, + Alignment::Center => Alignment::Center, + Alignment::Right => Alignment::Left, + }; + + Self::render_label(buf, labels.first().unwrap(), label_area, label_alignment); + + for (i, label) in labels[1..labels.len() - 1].iter().enumerate() { + // We add 1 to x (and width-1 below) to leave at least one space before each intermediate labels + let x = graph_area.left() + (i + 1) as u16 * width_between_ticks + 1; + let label_area = Rect::new(x, y, width_between_ticks.saturating_sub(1), 1); + + Self::render_label(buf, label, label_area, Alignment::Center); } + + let x = graph_area.right() - width_between_ticks; + let label_area = Rect::new(x, y, width_between_ticks, 1); + // The last label should be aligned Right to be at the edge of the graph area + Self::render_label(buf, labels.last().unwrap(), label_area, Alignment::Right); + } + + fn first_x_label_area( + &self, + y: u16, + label_width: u16, + max_width_after_y_axis: u16, + chart_area: Rect, + graph_area: Rect, + ) -> Rect { + let (min_x, max_x) = match self.x_axis.labels_alignment { + Alignment::Left => (chart_area.left(), graph_area.left()), + Alignment::Center => ( + chart_area.left(), + graph_area.left() + max_width_after_y_axis.min(label_width), + ), + Alignment::Right => ( + graph_area.left().saturating_sub(1), + graph_area.left() + max_width_after_y_axis, + ), + }; + + Rect::new(min_x, y, max_x - min_x, 1) + } + + fn render_label(buf: &mut Buffer, label: &Span, label_area: Rect, alignment: Alignment) { + let label_width = label.width() as u16; + let bounded_label_width = label_area.width.min(label_width); + + let x = match alignment { + Alignment::Left => label_area.left(), + Alignment::Center => label_area.left() + label_area.width / 2 - bounded_label_width / 2, + Alignment::Right => label_area.right() - bounded_label_width, + }; + + buf.set_span(x, label_area.top(), label, bounded_label_width); } fn render_y_labels( @@ -405,11 +476,16 @@ impl<'a> Chart<'a> { }; let labels = self.y_axis.labels.as_ref().unwrap(); let labels_len = labels.len() as u16; - let label_width = graph_area.left().saturating_sub(chart_area.left()); for (i, label) in labels.iter().enumerate() { let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1); if dy < graph_area.bottom() { - buf.set_span(x, graph_area.bottom() - 1 - dy, label, label_width as u16); + let label_area = Rect::new( + x, + graph_area.bottom().saturating_sub(1) - dy, + (graph_area.left() - chart_area.left()).saturating_sub(1), + 1, + ); + Self::render_label(buf, label, label_area, self.y_axis.labels_alignment); } } } diff --git a/tests/widgets_chart.rs b/tests/widgets_chart.rs index 8bbe0801..e8f6028a 100644 --- a/tests/widgets_chart.rs +++ b/tests/widgets_chart.rs @@ -1,3 +1,4 @@ +use tui::layout::Alignment; use tui::{ backend::TestBackend, buffer::Buffer, @@ -13,6 +14,22 @@ fn create_labels<'a>(labels: &'a [&'a str]) -> Vec> { labels.iter().map(|l| Span::from(*l)).collect() } +fn axis_test_case(width: u16, height: u16, x_axis: Axis, y_axis: Axis, lines: Vec) +where + S: AsRef, +{ + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let chart = Chart::new(vec![]).x_axis(x_axis).y_axis(y_axis); + f.render_widget(chart, f.size()); + }) + .unwrap(); + let expected = Buffer::with_lines(lines); + terminal.backend().assert_buffer(&expected); +} + #[test] fn widgets_chart_can_render_on_small_areas() { let test_case = |width, height| { @@ -49,33 +66,26 @@ fn widgets_chart_can_render_on_small_areas() { #[test] fn widgets_chart_handles_long_labels() { - let test_case = |x_labels, y_labels, lines| { - let backend = TestBackend::new(10, 5); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| { - let datasets = vec![Dataset::default() - .marker(symbols::Marker::Braille) - .style(Style::default().fg(Color::Magenta)) - .data(&[(2.0, 2.0)])]; - let mut x_axis = Axis::default().bounds([0.0, 1.0]); - if let Some((left_label, right_label)) = x_labels { - x_axis = x_axis.labels(vec![Span::from(left_label), Span::from(right_label)]); - } - let mut y_axis = Axis::default().bounds([0.0, 1.0]); - if let Some((left_label, right_label)) = y_labels { - y_axis = y_axis.labels(vec![Span::from(left_label), Span::from(right_label)]); - } - let chart = Chart::new(datasets).x_axis(x_axis).y_axis(y_axis); - f.render_widget(chart, f.size()); - }) - .unwrap(); - let expected = Buffer::with_lines(lines); - terminal.backend().assert_buffer(&expected); + let test_case = |x_labels, y_labels, x_alignment, lines| { + let mut x_axis = Axis::default().bounds([0.0, 1.0]); + if let Some((left_label, right_label)) = x_labels { + x_axis = x_axis + .labels(vec![Span::from(left_label), Span::from(right_label)]) + .labels_alignment(x_alignment); + } + + let mut y_axis = Axis::default().bounds([0.0, 1.0]); + if let Some((left_label, right_label)) = y_labels { + y_axis = y_axis.labels(vec![Span::from(left_label), Span::from(right_label)]); + } + + axis_test_case(10, 5, x_axis, y_axis, lines); }; + test_case( Some(("AAAA", "B")), None, + Alignment::Left, vec![ " ", " ", @@ -87,6 +97,7 @@ fn widgets_chart_handles_long_labels() { test_case( Some(("A", "BBBB")), None, + Alignment::Left, vec![ " ", " ", @@ -98,6 +109,7 @@ fn widgets_chart_handles_long_labels() { test_case( Some(("AAAAAAAAAAA", "B")), None, + Alignment::Left, vec![ " ", " ", @@ -109,6 +121,7 @@ fn widgets_chart_handles_long_labels() { test_case( Some(("A", "B")), Some(("CCCCCCC", "D")), + Alignment::Left, vec![ "D │ ", " │ ", @@ -117,6 +130,129 @@ fn widgets_chart_handles_long_labels() { " A B", ], ); + test_case( + Some(("AAAAAAAAAA", "B")), + Some(("C", "D")), + Alignment::Center, + vec![ + "D │ ", + " │ ", + "C │ ", + " └──────", + "AAAAAAA B", + ], + ); + test_case( + Some(("AAAAAAA", "B")), + Some(("C", "D")), + Alignment::Right, + vec![ + "D│ ", + " │ ", + "C│ ", + " └────────", + " AAAAA B", + ], + ); + test_case( + Some(("AAAAAAA", "BBBBBBB")), + Some(("C", "D")), + Alignment::Right, + vec![ + "D│ ", + " │ ", + "C│ ", + " └────────", + " AAAAABBBB", + ], + ); +} + +#[test] +fn widgets_chart_handles_x_axis_labels_alignments() { + let test_case = |y_alignment, lines| { + let x_axis = Axis::default() + .labels(vec![Span::from("AAAA"), Span::from("B"), Span::from("C")]) + .labels_alignment(y_alignment); + + let y_axis = Axis::default(); + + axis_test_case(10, 5, x_axis, y_axis, lines); + }; + + test_case( + Alignment::Left, + vec![ + " ", + " ", + " ", + " ───────", + "AAA B C", + ], + ); + test_case( + Alignment::Center, + vec![ + " ", + " ", + " ", + " ────────", + "AAAA B C", + ], + ); + test_case( + Alignment::Right, + vec![ + " ", + " ", + " ", + "──────────", + "AAA B C", + ], + ); +} + +#[test] +fn widgets_chart_handles_y_axis_labels_alignments() { + let test_case = |y_alignment, lines| { + let x_axis = Axis::default().labels(create_labels(&["AAAAA", "B"])); + + let y_axis = Axis::default() + .labels(create_labels(&["C", "D"])) + .labels_alignment(y_alignment); + + axis_test_case(20, 5, x_axis, y_axis, lines); + }; + test_case( + Alignment::Left, + vec![ + "D │ ", + " │ ", + "C │ ", + " └───────────────", + "AAAAA B", + ], + ); + test_case( + Alignment::Center, + vec![ + " D │ ", + " │ ", + " C │ ", + " └───────────────", + "AAAAA B", + ], + ); + test_case( + Alignment::Right, + vec![ + " D│ ", + " │ ", + " C│ ", + " └───────────────", + "AAAAA B", + ], + ); } #[test] @@ -329,7 +465,7 @@ fn widgets_chart_can_have_a_legend() { "│ │ •• •• │", "│0.0 │• X Axis│", "│ └─────────────────────────────────────────────────────│", - "│ 0.0 50.0 100.0 │", + "│ 0.0 50.0 100.0│", "└──────────────────────────────────────────────────────────┘", ]);