mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-26 14:40:30 +00:00
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:
parent
6069d89dee
commit
853d9047b0
2 changed files with 265 additions and 53 deletions
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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│",
|
||||||
"└──────────────────────────────────────────────────────────┘",
|
"└──────────────────────────────────────────────────────────┘",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue