mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-22 12:43:16 +00:00
feat(widgets/chart): add option to set the position of legend (#378)
This commit is contained in:
parent
113b4b7a4e
commit
b8f71c0d6e
3 changed files with 480 additions and 41 deletions
|
@ -221,7 +221,8 @@ fn ui(f: &mut Frame, app: &App) {
|
|||
.style(Style::default().fg(Color::Gray))
|
||||
.bounds([0.0, 5.0])
|
||||
.labels(vec!["0".bold(), "2.5".into(), "5.0".bold()]),
|
||||
);
|
||||
)
|
||||
.hidden_legend_constraints((Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)));
|
||||
f.render_widget(chart, chunks[1]);
|
||||
|
||||
let datasets = vec![Dataset::default()
|
||||
|
@ -249,6 +250,8 @@ fn ui(f: &mut Frame, app: &App) {
|
|||
.style(Style::default().fg(Color::Gray))
|
||||
.bounds([0.0, 5.0])
|
||||
.labels(vec!["0".bold(), "2.5".into(), "5".bold()]),
|
||||
);
|
||||
)
|
||||
.legend_position(Some(LegendPosition::TopLeft))
|
||||
.hidden_legend_constraints((Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)));
|
||||
f.render_widget(chart, chunks[2]);
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ use bitflags::bitflags;
|
|||
pub use self::{
|
||||
barchart::{Bar, BarChart, BarGroup},
|
||||
block::{Block, BorderType, Padding},
|
||||
chart::{Axis, Chart, Dataset, GraphType},
|
||||
chart::{Axis, Chart, Dataset, GraphType, LegendPosition},
|
||||
clear::Clear,
|
||||
gauge::{Gauge, LineGauge},
|
||||
list::{List, ListDirection, ListItem, ListState},
|
||||
|
|
|
@ -91,6 +91,113 @@ pub enum GraphType {
|
|||
Line,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum LegendPosition {
|
||||
Top,
|
||||
#[default]
|
||||
TopRight,
|
||||
TopLeft,
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
}
|
||||
|
||||
impl LegendPosition {
|
||||
fn layout(
|
||||
&self,
|
||||
area: Rect,
|
||||
legend_width: u16,
|
||||
legend_height: u16,
|
||||
x_title_width: u16,
|
||||
y_title_width: u16,
|
||||
) -> Option<Rect> {
|
||||
let mut height_margin = (area.height - legend_height) as i32;
|
||||
if x_title_width != 0 {
|
||||
height_margin -= 1;
|
||||
}
|
||||
if y_title_width != 0 {
|
||||
height_margin -= 1;
|
||||
}
|
||||
if height_margin < 0 {
|
||||
return None;
|
||||
};
|
||||
|
||||
let (x, y) = match self {
|
||||
Self::TopRight => {
|
||||
if legend_width + y_title_width > area.width {
|
||||
(area.right() - legend_width, area.top() + 1)
|
||||
} else {
|
||||
(area.right() - legend_width, area.top())
|
||||
}
|
||||
}
|
||||
Self::TopLeft => {
|
||||
if y_title_width != 0 {
|
||||
(area.left(), area.top() + 1)
|
||||
} else {
|
||||
(area.left(), area.top())
|
||||
}
|
||||
}
|
||||
Self::Top => {
|
||||
let x = (area.width - legend_width) / 2;
|
||||
if area.left() + y_title_width > x {
|
||||
(area.left() + x, area.top() + 1)
|
||||
} else {
|
||||
(area.left() + x, area.top())
|
||||
}
|
||||
}
|
||||
Self::Left => {
|
||||
let mut y = (area.height - legend_height) / 2;
|
||||
if y_title_width != 0 {
|
||||
y += 1;
|
||||
}
|
||||
if x_title_width != 0 {
|
||||
y = y.saturating_sub(1);
|
||||
}
|
||||
(area.left(), area.top() + y)
|
||||
}
|
||||
Self::Right => {
|
||||
let mut y = (area.height - legend_height) / 2;
|
||||
if y_title_width != 0 {
|
||||
y += 1;
|
||||
}
|
||||
if x_title_width != 0 {
|
||||
y = y.saturating_sub(1);
|
||||
}
|
||||
(area.right() - legend_width, area.top() + y)
|
||||
}
|
||||
Self::BottomLeft => {
|
||||
if x_title_width + legend_width > area.width {
|
||||
(area.left(), area.bottom() - legend_height - 1)
|
||||
} else {
|
||||
(area.left(), area.bottom() - legend_height)
|
||||
}
|
||||
}
|
||||
Self::BottomRight => {
|
||||
if x_title_width != 0 {
|
||||
(
|
||||
area.right() - legend_width,
|
||||
area.bottom() - legend_height - 1,
|
||||
)
|
||||
} else {
|
||||
(area.right() - legend_width, area.bottom() - legend_height)
|
||||
}
|
||||
}
|
||||
Self::Bottom => {
|
||||
let x = area.left() + (area.width - legend_width) / 2;
|
||||
if x + legend_width > area.right() - x_title_width {
|
||||
(x, area.bottom() - legend_height - 1)
|
||||
} else {
|
||||
(x, area.bottom() - legend_height)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(Rect::new(x, y, legend_width, legend_height))
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of data points
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Dataset<'a> {
|
||||
|
@ -210,6 +317,9 @@ pub struct Chart<'a> {
|
|||
style: Style,
|
||||
/// Constraints used to determine whether the legend should be shown or not
|
||||
hidden_legend_constraints: (Constraint, Constraint),
|
||||
/// The position detnermine where the legenth is shown or hide regaurdless of
|
||||
/// `hidden_legend_constraints`
|
||||
legend_position: Option<LegendPosition>,
|
||||
}
|
||||
|
||||
impl<'a> Chart<'a> {
|
||||
|
@ -221,6 +331,7 @@ impl<'a> Chart<'a> {
|
|||
style: Style::default(),
|
||||
datasets,
|
||||
hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
|
||||
legend_position: Some(LegendPosition::default()),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -269,6 +380,23 @@ impl<'a> Chart<'a> {
|
|||
self
|
||||
}
|
||||
|
||||
/// Set the position of a legend or hide it.
|
||||
///
|
||||
/// If [`None`], hide the legend even if satisfied with
|
||||
/// [`hidden_legend_constraints`](Self::hidden_legend_constraints)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use ratatui::widgets::{Chart, LegendPosition};
|
||||
/// let _chart: Chart = Chart::new(vec![])
|
||||
/// .legend_position(Some(LegendPosition::TopLeft));
|
||||
/// ```
|
||||
pub fn legend_position(mut self, position: Option<LegendPosition>) -> Chart<'a> {
|
||||
self.legend_position = position;
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the internal layout of the chart given the area. If the area is too small some
|
||||
/// elements may be automatically hidden
|
||||
fn layout(&self, area: Rect) -> ChartLayout {
|
||||
|
@ -315,27 +443,38 @@ impl<'a> Chart<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
|
||||
let legend_width = inner_width + 2;
|
||||
let legend_height = self.datasets.len() as u16 + 2;
|
||||
let max_legend_width = self
|
||||
.hidden_legend_constraints
|
||||
.0
|
||||
.apply(layout.graph_area.width);
|
||||
let max_legend_height = self
|
||||
.hidden_legend_constraints
|
||||
.1
|
||||
.apply(layout.graph_area.height);
|
||||
if inner_width > 0
|
||||
&& legend_width < max_legend_width
|
||||
&& legend_height < max_legend_height
|
||||
{
|
||||
layout.legend_area = Some(Rect::new(
|
||||
layout.graph_area.right() - legend_width,
|
||||
layout.graph_area.top(),
|
||||
legend_width,
|
||||
legend_height,
|
||||
));
|
||||
if let Some(legend_position) = self.legend_position {
|
||||
if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
|
||||
let legend_width = inner_width + 2;
|
||||
let legend_height = self.datasets.len() as u16 + 2;
|
||||
let max_legend_width = self
|
||||
.hidden_legend_constraints
|
||||
.0
|
||||
.apply(layout.graph_area.width);
|
||||
let max_legend_height = self
|
||||
.hidden_legend_constraints
|
||||
.1
|
||||
.apply(layout.graph_area.height);
|
||||
if inner_width > 0
|
||||
&& legend_width <= max_legend_width
|
||||
&& legend_height <= max_legend_height
|
||||
{
|
||||
layout.legend_area = legend_position.layout(
|
||||
layout.graph_area,
|
||||
legend_width,
|
||||
legend_height,
|
||||
layout
|
||||
.title_x
|
||||
.and(self.x_axis.title.as_ref())
|
||||
.map(|t| t.width() as u16)
|
||||
.unwrap_or_default(),
|
||||
layout
|
||||
.title_y
|
||||
.and(self.y_axis.title.as_ref())
|
||||
.map(|t| t.width() as u16)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
layout
|
||||
|
@ -559,24 +698,12 @@ impl<'a> Widget for Chart<'a> {
|
|||
.render(graph_area, buf);
|
||||
}
|
||||
|
||||
if let Some(legend_area) = layout.legend_area {
|
||||
buf.set_style(legend_area, original_style);
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.render(legend_area, buf);
|
||||
for (i, dataset) in self.datasets.iter().enumerate() {
|
||||
buf.set_string(
|
||||
legend_area.x + 1,
|
||||
legend_area.y + 1 + i as u16,
|
||||
&dataset.name,
|
||||
dataset.style,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((x, y)) = layout.title_x {
|
||||
let title = self.x_axis.title.unwrap();
|
||||
let width = title.width() as u16;
|
||||
let width = graph_area
|
||||
.right()
|
||||
.saturating_sub(x)
|
||||
.min(title.width() as u16);
|
||||
buf.set_style(
|
||||
Rect {
|
||||
x,
|
||||
|
@ -591,7 +718,10 @@ impl<'a> Widget for Chart<'a> {
|
|||
|
||||
if let Some((x, y)) = layout.title_y {
|
||||
let title = self.y_axis.title.unwrap();
|
||||
let width = title.width() as u16;
|
||||
let width = graph_area
|
||||
.right()
|
||||
.saturating_sub(x)
|
||||
.min(title.width() as u16);
|
||||
buf.set_style(
|
||||
Rect {
|
||||
x,
|
||||
|
@ -603,6 +733,21 @@ impl<'a> Widget for Chart<'a> {
|
|||
);
|
||||
buf.set_line(x, y, &title, width);
|
||||
}
|
||||
|
||||
if let Some(legend_area) = layout.legend_area {
|
||||
buf.set_style(legend_area, original_style);
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.render(legend_area, buf);
|
||||
for (i, dataset) in self.datasets.iter().enumerate() {
|
||||
buf.set_string(
|
||||
legend_area.x + 1,
|
||||
legend_area.y + 1 + i as u16,
|
||||
&dataset.name,
|
||||
dataset.style,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -745,4 +890,295 @@ mod tests {
|
|||
|
||||
assert_eq!(buffer, Buffer::with_lines(vec![" ".repeat(8); 4]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chart_have_a_topleft_legend() {
|
||||
let chart = Chart::new(vec![Dataset::default().name("Ds1")])
|
||||
.legend_position(Some(LegendPosition::TopLeft));
|
||||
|
||||
let area = Rect::new(0, 0, 30, 20);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
|
||||
let expected = Buffer::with_lines(vec![
|
||||
"┌───┐ ",
|
||||
"│Ds1│ ",
|
||||
"└───┘ ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
|
||||
assert_eq!(buffer, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chart_have_a_long_y_axis_title_overlapping_legend() {
|
||||
let chart = Chart::new(vec![Dataset::default().name("Ds1")])
|
||||
.y_axis(Axis::default().title("The title overlap a legend."));
|
||||
|
||||
let area = Rect::new(0, 0, 30, 20);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
|
||||
let expected = Buffer::with_lines(vec![
|
||||
"The title overlap a legend. ",
|
||||
" ┌───┐",
|
||||
" │Ds1│",
|
||||
" └───┘",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
|
||||
assert_eq!(buffer, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chart_have_overflowed_y_axis() {
|
||||
let chart = Chart::new(vec![Dataset::default().name("Ds1")])
|
||||
.y_axis(Axis::default().title("The title overlap a legend."));
|
||||
|
||||
let area = Rect::new(0, 0, 10, 10);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
|
||||
let expected = Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
|
||||
assert_eq!(buffer, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legend_area_can_fit_same_chart_area() {
|
||||
let name = "Data";
|
||||
let chart = Chart::new(vec![Dataset::default().name(name)])
|
||||
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
|
||||
|
||||
let area = Rect::new(0, 0, name.len() as u16 + 2, 3);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
let expected = Buffer::with_lines(vec!["┌────┐", "│Data│", "└────┘"]);
|
||||
|
||||
[
|
||||
LegendPosition::TopLeft,
|
||||
LegendPosition::Top,
|
||||
LegendPosition::TopRight,
|
||||
LegendPosition::Left,
|
||||
LegendPosition::Right,
|
||||
LegendPosition::Bottom,
|
||||
LegendPosition::BottomLeft,
|
||||
LegendPosition::BottomRight,
|
||||
]
|
||||
.iter()
|
||||
.for_each(|&position| {
|
||||
let chart = chart.clone().legend_position(Some(position));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(buffer, expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legend_of_chart_have_odd_margin_size() {
|
||||
let name = "Data";
|
||||
let base_chart = Chart::new(vec![Dataset::default().name(name)])
|
||||
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
|
||||
|
||||
let area = Rect::new(0, 0, name.len() as u16 + 2 + 3, 3 + 3);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::TopLeft));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
"┌────┐ ",
|
||||
"│Data│ ",
|
||||
"└────┘ ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
buffer.reset();
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::Top));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ┌────┐ ",
|
||||
" │Data│ ",
|
||||
" └────┘ ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::TopRight));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ┌────┐",
|
||||
" │Data│",
|
||||
" └────┘",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::Left));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
"┌────┐ ",
|
||||
"│Data│ ",
|
||||
"└────┘ ",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
buffer.reset();
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::Right));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ┌────┐",
|
||||
" │Data│",
|
||||
" └────┘",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::BottomLeft));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
"┌────┐ ",
|
||||
"│Data│ ",
|
||||
"└────┘ ",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::Bottom));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ┌────┐ ",
|
||||
" │Data│ ",
|
||||
" └────┘ ",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart
|
||||
.clone()
|
||||
.legend_position(Some(LegendPosition::BottomRight));
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ┌────┐",
|
||||
" │Data│",
|
||||
" └────┘",
|
||||
])
|
||||
);
|
||||
|
||||
let chart = base_chart.clone().legend_position(None);
|
||||
buffer.reset();
|
||||
chart.render(buffer.area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer,
|
||||
Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue