feat(widgets/chart): add option to control alignment of axis labels (#568)

* feat(chart): allow custom alignment of first X-Axis label

* refactor(chart): rename ambiguous function parameter

* feat(chart): allow custom alignment of Y-Axis labels

* refactor(chart): refactor axis test cases

* refactor(chart): rename minor variable

* fix(chart): force centered x-axis label near Y-Axis

* fix(chart): fix subtract overflow on small rendering area

* refactor(chart): rename alignment property

* refactor(chart): merge two nested conditions

* refactor(chart): decompose x labels rendering loop
This commit is contained in:
theogilbert 2021-12-23 19:02:10 +01:00 committed by GitHub
parent 6069d89dee
commit 853d9047b0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 265 additions and 53 deletions

View file

@ -1,3 +1,8 @@
use std::{borrow::Cow, cmp::max};
use unicode_width::UnicodeWidthStr;
use crate::layout::Alignment;
use crate::{ use crate::{
buffer::Buffer, buffer::Buffer,
layout::{Constraint, Rect}, layout::{Constraint, Rect},
@ -9,8 +14,6 @@ use crate::{
Block, Borders, Widget, Block, Borders, Widget,
}, },
}; };
use std::{borrow::Cow, cmp::max};
use unicode_width::UnicodeWidthStr;
/// An X or Y axis for the chart widget /// An X or Y axis for the chart widget
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -23,6 +26,8 @@ pub struct Axis<'a> {
labels: Option<Vec<Span<'a>>>, labels: Option<Vec<Span<'a>>>,
/// The style used to draw the axis itself /// The style used to draw the axis itself
style: Style, style: Style,
/// The alignment of the labels of the Axis
labels_alignment: Alignment,
} }
impl<'a> Default for Axis<'a> { impl<'a> Default for Axis<'a> {
@ -32,6 +37,7 @@ impl<'a> Default for Axis<'a> {
bounds: [0.0, 0.0], bounds: [0.0, 0.0],
labels: None, labels: None,
style: Default::default(), style: Default::default(),
labels_alignment: Alignment::Left,
} }
} }
} }
@ -71,6 +77,15 @@ impl<'a> Axis<'a> {
self.style = style; self.style = style;
self 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 /// 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)); 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() { if self.x_axis.labels.is_some() && y > area.top() {
layout.axis_x = Some(y); layout.axis_x = Some(y);
@ -338,17 +353,26 @@ impl<'a> Chart<'a> {
layout 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 let mut max_width = self
.y_axis .y_axis
.labels .labels
.as_ref() .as_ref()
.map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16) .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16)
.unwrap_or_default(); .unwrap_or_default();
if let Some(ref x_labels) = self.x_axis.labels {
if !x_labels.is_empty() { if let Some(first_x_label) = self.x_axis.labels.as_ref().and_then(|labels| labels.get(0)) {
max_width = max(max_width, x_labels[0].content.width() as u16); 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 // 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) max_width.min(area.width / 3)
@ -370,26 +394,73 @@ impl<'a> Chart<'a> {
if labels_len < 2 { if labels_len < 2 {
return; return;
} }
let width_between_ticks = graph_area.width / (labels_len - 1);
for (i, label) in labels.iter().enumerate() { let width_between_ticks = graph_area.width / labels_len;
let label_width = label.width() as u16;
let label_width = if i == 0 { let label_area = self.first_x_label_area(
// the first label is put between the left border of the chart and the y axis. y,
graph_area labels.first().unwrap().width() as u16,
.left() width_between_ticks,
.saturating_sub(chart_area.left()) chart_area,
.min(label_width) graph_area,
} else { );
// other labels are put on the left of each tick on the x axis
width_between_ticks.min(label_width) let label_alignment = match self.x_axis.labels_alignment {
}; Alignment::Left => Alignment::Right,
buf.set_span( Alignment::Center => Alignment::Center,
graph_area.left() + i as u16 * width_between_ticks - label_width, Alignment::Right => Alignment::Left,
y, };
label,
label_width, 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( fn render_y_labels(
@ -405,11 +476,16 @@ impl<'a> Chart<'a> {
}; };
let labels = self.y_axis.labels.as_ref().unwrap(); let labels = self.y_axis.labels.as_ref().unwrap();
let labels_len = labels.len() as u16; 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() { for (i, label) in labels.iter().enumerate() {
let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1); let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
if dy < graph_area.bottom() { 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);
} }
} }
} }

View file

@ -1,3 +1,4 @@
use tui::layout::Alignment;
use tui::{ use tui::{
backend::TestBackend, backend::TestBackend,
buffer::Buffer, buffer::Buffer,
@ -13,6 +14,22 @@ fn create_labels<'a>(labels: &'a [&'a str]) -> Vec<Span<'a>> {
labels.iter().map(|l| Span::from(*l)).collect() labels.iter().map(|l| Span::from(*l)).collect()
} }
fn axis_test_case<S>(width: u16, height: u16, x_axis: Axis, y_axis: Axis, lines: Vec<S>)
where
S: AsRef<str>,
{
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] #[test]
fn widgets_chart_can_render_on_small_areas() { fn widgets_chart_can_render_on_small_areas() {
let test_case = |width, height| { let test_case = |width, height| {
@ -49,33 +66,26 @@ fn widgets_chart_can_render_on_small_areas() {
#[test] #[test]
fn widgets_chart_handles_long_labels() { fn widgets_chart_handles_long_labels() {
let test_case = |x_labels, y_labels, lines| { let test_case = |x_labels, y_labels, x_alignment, lines| {
let backend = TestBackend::new(10, 5); let mut x_axis = Axis::default().bounds([0.0, 1.0]);
let mut terminal = Terminal::new(backend).unwrap(); if let Some((left_label, right_label)) = x_labels {
terminal x_axis = x_axis
.draw(|f| { .labels(vec![Span::from(left_label), Span::from(right_label)])
let datasets = vec![Dataset::default() .labels_alignment(x_alignment);
.marker(symbols::Marker::Braille) }
.style(Style::default().fg(Color::Magenta))
.data(&[(2.0, 2.0)])]; let mut y_axis = Axis::default().bounds([0.0, 1.0]);
let mut x_axis = Axis::default().bounds([0.0, 1.0]); if let Some((left_label, right_label)) = y_labels {
if let Some((left_label, right_label)) = x_labels { y_axis = y_axis.labels(vec![Span::from(left_label), Span::from(right_label)]);
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]); axis_test_case(10, 5, x_axis, y_axis, lines);
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);
}; };
test_case( test_case(
Some(("AAAA", "B")), Some(("AAAA", "B")),
None, None,
Alignment::Left,
vec![ vec![
" ", " ",
" ", " ",
@ -87,6 +97,7 @@ fn widgets_chart_handles_long_labels() {
test_case( test_case(
Some(("A", "BBBB")), Some(("A", "BBBB")),
None, None,
Alignment::Left,
vec![ vec![
" ", " ",
" ", " ",
@ -98,6 +109,7 @@ fn widgets_chart_handles_long_labels() {
test_case( test_case(
Some(("AAAAAAAAAAA", "B")), Some(("AAAAAAAAAAA", "B")),
None, None,
Alignment::Left,
vec![ vec![
" ", " ",
" ", " ",
@ -109,6 +121,7 @@ fn widgets_chart_handles_long_labels() {
test_case( test_case(
Some(("A", "B")), Some(("A", "B")),
Some(("CCCCCCC", "D")), Some(("CCCCCCC", "D")),
Alignment::Left,
vec![ vec![
"D │ ", "D │ ",
"", "",
@ -117,6 +130,129 @@ fn widgets_chart_handles_long_labels() {
" A B", " 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] #[test]
@ -329,7 +465,7 @@ fn widgets_chart_can_have_a_legend() {
"│ │ •• •• │", "│ │ •• •• │",
"│0.0 │• X Axis│", "│0.0 │• X Axis│",
"│ └─────────────────────────────────────────────────────│", "│ └─────────────────────────────────────────────────────│",
"│ 0.0 50.0 100.0 ", "│ 0.0 50.0 100.0│",
"└──────────────────────────────────────────────────────────┘", "└──────────────────────────────────────────────────────────┘",
]); ]);