use ratatui::{
buffer::Buffer,
layout::{Direction, Rect},
style::{Color, Modifier, Style},
symbols,
text::Line,
widgets::{Bar, BarChart, BarGroup, Widget},
};
use crate::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BarChartStyle {
pub foreground: Color,
pub background: Color,
pub bar: Color,
pub value_foreground: Color,
pub label_foreground: Color,
}
impl BarChartStyle {
#[must_use]
pub const fn fallback() -> Self {
Self {
foreground: Color::Reset,
background: Color::Reset,
bar: Color::Cyan,
value_foreground: Color::Reset,
label_foreground: Color::DarkGray,
}
}
#[must_use]
pub const fn from_theme(theme: &Theme) -> Self {
Self {
foreground: theme.foreground,
background: theme.field,
bar: theme.primary,
value_foreground: theme.primary_foreground,
label_foreground: theme.muted_foreground,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BarChartWidget<'a> {
data: BarChartData<'a>,
style: BarChartStyle,
max_value: Option<u64>,
direction: Direction,
bar_width: u16,
bar_gap: u16,
group_gap: u16,
bar_set: symbols::bar::Set<'a>,
show_values: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BarChartData<'a> {
Bars(Vec<Bar<'a>>),
Groups(Vec<BarChartGroup<'a>>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BarChartGroup<'a> {
label: Option<Line<'a>>,
bars: Vec<Bar<'a>>,
}
impl<'a> BarChartGroup<'a> {
#[must_use]
pub fn new(bars: impl IntoIterator<Item = Bar<'a>>) -> Self {
Self {
label: None,
bars: bars.into_iter().collect(),
}
}
#[must_use]
pub fn label(mut self, label: impl Into<Line<'a>>) -> Self {
self.label = Some(label.into());
self
}
}
impl<'a> BarChartWidget<'a> {
#[must_use]
pub fn new(bars: impl IntoIterator<Item = Bar<'a>>) -> Self {
Self::with_data(BarChartData::Bars(bars.into_iter().collect()))
}
#[must_use]
pub fn vertical(bars: impl IntoIterator<Item = Bar<'a>>) -> Self {
Self::new(bars)
}
#[must_use]
pub fn horizontal(bars: impl IntoIterator<Item = Bar<'a>>) -> Self {
Self::new(bars).direction(Direction::Horizontal)
}
#[must_use]
pub fn grouped(groups: impl IntoIterator<Item = BarChartGroup<'a>>) -> Self {
Self::with_data(BarChartData::Groups(groups.into_iter().collect()))
}
fn with_data(data: BarChartData<'a>) -> Self {
Self {
data,
style: BarChartStyle::fallback(),
max_value: None,
direction: Direction::Vertical,
bar_width: 3,
bar_gap: 1,
group_gap: 0,
bar_set: symbols::bar::NINE_LEVELS,
show_values: true,
}
}
#[must_use]
pub const fn themed(mut self, theme: &Theme) -> Self {
self.style = BarChartStyle::from_theme(theme);
self
}
#[must_use]
pub const fn style(mut self, style: BarChartStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn max_value(mut self, max_value: u64) -> Self {
self.max_value = Some(max_value);
self
}
#[must_use]
pub const fn direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
#[must_use]
pub const fn show_values(mut self, show_values: bool) -> Self {
self.show_values = show_values;
self
}
#[must_use]
pub fn width(&self) -> u16 {
if self.direction == Direction::Vertical {
self.grouping_span()
} else {
0
}
}
#[must_use]
pub fn height(&self) -> u16 {
if self.direction == Direction::Horizontal {
self.grouping_span()
} else {
0
}
}
#[must_use]
pub const fn bar_width(mut self, width: u16) -> Self {
self.bar_width = width;
self
}
#[must_use]
pub const fn bar_gap(mut self, gap: u16) -> Self {
self.bar_gap = gap;
self
}
#[must_use]
pub const fn group_gap(mut self, gap: u16) -> Self {
self.group_gap = gap;
self
}
#[must_use]
pub fn bar_set(mut self, bar_set: symbols::bar::Set<'a>) -> Self {
self.bar_set = bar_set;
self
}
fn grouping_span(&self) -> u16 {
let group_spans: Vec<u16> = match &self.data {
BarChartData::Bars(bars) => vec![bar_span(bars.len(), self.bar_width, self.bar_gap)],
BarChartData::Groups(groups) => groups
.iter()
.map(|group| bar_span(group.bars.len(), self.bar_width, self.bar_gap))
.collect(),
};
group_spans
.into_iter()
.enumerate()
.fold(0, |span, (index, group)| {
span.saturating_add(group).saturating_add(if index > 0 {
self.group_gap
} else {
0
})
})
}
}
fn bar_span(count: usize, width: u16, gap: u16) -> u16 {
let count = u16::try_from(count).unwrap_or(u16::MAX);
count
.saturating_mul(width)
.saturating_add(count.saturating_sub(1).saturating_mul(gap))
}
impl<'a> Widget for BarChartWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let show_values = self.show_values;
let strip_values = move |bars: Vec<Bar<'a>>| -> Vec<Bar<'a>> {
if show_values {
bars
} else {
bars.into_iter().map(|bar| bar.text_value("")).collect()
}
};
let mut chart = match self.data {
BarChartData::Bars(bars) => BarChart::new(strip_values(bars)),
BarChartData::Groups(groups) => {
let groups: Vec<BarGroup<'a>> = groups
.into_iter()
.map(|group| {
let bars = strip_values(group.bars);
match group.label {
Some(label) => BarGroup::with_label(label, bars),
None => BarGroup::new(bars),
}
})
.collect();
BarChart::grouped(groups)
}
};
chart = chart
.style(
Style::default()
.fg(self.style.foreground)
.bg(self.style.background),
)
.bar_style(
Style::default()
.fg(self.style.bar)
.bg(self.style.background),
)
.value_style(
Style::default()
.fg(self.style.value_foreground)
.bg(self.style.bar)
.add_modifier(Modifier::BOLD),
)
.label_style(
Style::default()
.fg(self.style.label_foreground)
.bg(self.style.background),
)
.bar_width(self.bar_width)
.bar_gap(self.bar_gap)
.group_gap(self.group_gap)
.bar_set(self.bar_set)
.direction(self.direction);
if let Some(max_value) = self.max_value {
chart = chart.max(max_value);
}
chart.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_accept_any_iterator_of_bars() {
let readings = [3u64, 0, 7];
let from_iterator = BarChartWidget::new(
readings
.iter()
.filter(|value| **value > 0)
.map(|value| Bar::default().value(*value)),
);
let from_vec = BarChartWidget::new(vec![Bar::default().value(3), Bar::default().value(7)]);
assert_eq!(
from_iterator.width(),
from_vec.width(),
"the filtered iterator yields the same two bars a vec would"
);
let grouped = BarChartWidget::grouped(
["a", "b"]
.into_iter()
.map(|label| BarChartGroup::new([Bar::default().value(1)]).label(label)),
);
assert_eq!(
grouped.width(),
BarChartWidget::grouped(vec![
BarChartGroup::new([Bar::default().value(1)]).label("a"),
BarChartGroup::new([Bar::default().value(1)]).label("b"),
])
.width()
);
}
#[test]
fn themed_bars_paint_the_primary_color() {
let theme = Theme::default_dark();
let area = Rect::new(0, 0, 3, 3);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![Bar::default().value(9)])
.themed(&theme)
.show_values(false)
.render(area, &mut buffer);
let cell = buffer.cell((0, 2)).expect("bar cell");
assert_eq!(cell.fg, theme.primary);
assert_eq!(cell.bg, theme.field);
}
#[test]
fn explicit_style_overrides_paint_exact_colors() {
let style = BarChartStyle {
foreground: Color::White,
background: Color::Black,
bar: Color::Magenta,
value_foreground: Color::Yellow,
label_foreground: Color::Gray,
};
let area = Rect::new(0, 0, 1, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![Bar::default().value(1)])
.style(style)
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
let cell = buffer.cell((0, 0)).expect("bar cell");
assert_eq!(cell.fg, style.bar);
assert_eq!(cell.bg, style.background);
}
#[test]
fn per_bar_style_patches_over_the_chart_bar_color() {
let style = BarChartStyle {
bar: Color::Magenta,
..BarChartStyle::fallback()
};
let area = Rect::new(0, 0, 2, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![
Bar::default().value(1),
Bar::default()
.value(1)
.style(Style::default().fg(Color::Red)),
])
.style(style)
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(
buffer.cell((0, 0)).expect("chart-colored bar").fg,
style.bar
);
assert_eq!(buffer.cell((1, 0)).expect("per-bar color").fg, Color::Red);
}
#[test]
fn a_recolored_bar_keeps_the_chart_wide_value_background() {
let style = BarChartStyle {
bar: Color::Magenta,
value_foreground: Color::Yellow,
..BarChartStyle::fallback()
};
let area = Rect::new(0, 0, 1, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![
Bar::default()
.value(1)
.style(Style::default().fg(Color::Red)),
])
.style(style)
.max_value(1)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
let cell = buffer.cell((0, 0)).expect("value cell");
assert_eq!(cell.fg, style.value_foreground);
assert_eq!(cell.bg, style.bar);
}
#[test]
fn max_value_pins_the_scale_so_a_sub_max_bar_leaves_headroom() {
let area = Rect::new(0, 0, 1, 2);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![Bar::default().value(1)])
.max_value(2)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(buffer.cell((0, 0)).expect("headroom cell").symbol(), " ");
assert_eq!(buffer.cell((0, 1)).expect("bar cell").symbol(), "█");
}
#[test]
fn horizontal_bars_render_left_to_right() {
let area = Rect::new(0, 0, 4, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::horizontal(vec![Bar::default().value(1)])
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(buffer.cell((3, 0)).expect("bar end cell").symbol(), "█");
}
#[test]
fn horizontal_ordinary_labels_keep_their_own_style() {
let mut style = BarChartStyle::fallback();
style.foreground = Color::White;
style.label_foreground = Color::Yellow;
style.value_foreground = Color::Green;
let area = Rect::new(0, 0, 10, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::horizontal(vec![Bar::default().label("Plain").value(1)])
.style(style)
.max_value(1)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(
buffer.cell((0, 0)).expect("label cell").fg,
style.foreground,
"ratatui does not apply chart label_style to ordinary horizontal labels"
);
assert_eq!(
buffer.cell((6, 0)).expect("value cell").fg,
style.value_foreground
);
}
#[test]
fn direction_builder_changes_the_chart_orientation() {
let area = Rect::new(0, 0, 4, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![Bar::default().value(1)])
.direction(Direction::Horizontal)
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(buffer.cell((3, 0)).expect("bar end cell").symbol(), "█");
}
#[test]
fn custom_bar_set_controls_rendered_symbols() {
let area = Rect::new(0, 0, 1, 1);
let mut buffer = Buffer::empty(area);
let bar_set = symbols::bar::Set {
full: "x",
seven_eighths: "x",
three_quarters: "x",
five_eighths: "x",
half: "x",
three_eighths: "x",
one_quarter: "x",
one_eighth: "x",
empty: ".",
};
BarChartWidget::new(vec![Bar::default().value(1)])
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.bar_set(bar_set)
.render(area, &mut buffer);
assert_eq!(buffer.cell((0, 0)).expect("bar cell").symbol(), "x");
}
#[test]
fn group_gap_separates_two_groups() {
let area = Rect::new(0, 0, 3, 1);
let mut buffer = Buffer::empty(area);
let groups = vec![
BarChartGroup::new(vec![Bar::default().value(1)]),
BarChartGroup::new(vec![Bar::default().value(1)]),
];
BarChartWidget::grouped(groups)
.max_value(1)
.bar_width(1)
.bar_gap(0)
.group_gap(1)
.show_values(false)
.render(area, &mut buffer);
assert_eq!(buffer.cell((0, 0)).expect("first bar").symbol(), "█");
assert_eq!(buffer.cell((1, 0)).expect("group gap").symbol(), " ");
assert_eq!(buffer.cell((2, 0)).expect("second bar").symbol(), "█");
}
#[test]
fn grouped_horizontal_charts_render_bars_and_group_labels() {
let area = Rect::new(0, 0, 4, 4);
let mut buffer = Buffer::empty(area);
let groups = vec![
BarChartGroup::new(vec![Bar::default().value(1)]).label("G1"),
BarChartGroup::new(vec![Bar::default().value(1)]).label("G2"),
];
BarChartWidget::grouped(groups)
.direction(Direction::Horizontal)
.max_value(1)
.bar_width(1)
.bar_gap(0)
.group_gap(1)
.show_values(false)
.render(area, &mut buffer);
assert_eq!(buffer.cell((3, 0)).expect("first bar end").symbol(), "█");
assert_eq!(
buffer.cell((0, 1)).expect("first group label").symbol(),
"G"
);
assert_eq!(buffer.cell((3, 2)).expect("second bar end").symbol(), "█");
assert_eq!(
buffer.cell((0, 3)).expect("second group label").symbol(),
"G"
);
}
#[test]
fn grouped_horizontal_labels_distinguish_bar_and_group_style() {
use ratatui::style::Stylize;
let mut style = BarChartStyle::fallback();
style.foreground = Color::White;
style.label_foreground = Color::Yellow;
let area = Rect::new(0, 0, 10, 3);
let mut buffer = Buffer::empty(area);
let groups = vec![
BarChartGroup::new(vec![
Bar::default().label("Plain").value(1),
Bar::default().label(Line::from("Red").red()).value(1),
])
.label("G"),
];
BarChartWidget::grouped(groups)
.style(style)
.direction(Direction::Horizontal)
.max_value(1)
.bar_width(1)
.bar_gap(0)
.group_gap(1)
.show_values(false)
.render(area, &mut buffer);
assert_eq!(
buffer.cell((0, 0)).expect("plain bar label").fg,
style.foreground
);
assert_eq!(
buffer.cell((0, 1)).expect("explicit bar label").fg,
Color::Red
);
assert_eq!(
buffer.cell((6, 2)).expect("group label").fg,
style.label_foreground
);
}
#[test]
fn custom_text_value_is_preserved() {
let area = Rect::new(0, 0, 2, 1);
let mut buffer = Buffer::empty(area);
BarChartWidget::new(vec![Bar::default().value(1).text_value("ok")])
.max_value(1)
.bar_width(2)
.bar_gap(0)
.render(area, &mut buffer);
assert_eq!(buffer.cell((0, 0)).expect("value start").symbol(), "o");
assert_eq!(buffer.cell((1, 0)).expect("value end").symbol(), "k");
}
#[test]
fn show_values_false_hides_standalone_bar_values() {
let area = Rect::new(0, 0, 1, 2);
let mut with_values = Buffer::empty(area);
let mut without_values = Buffer::empty(area);
let bars = || vec![Bar::default().value(9)];
BarChartWidget::new(bars())
.max_value(9)
.bar_width(1)
.bar_gap(0)
.render(area, &mut with_values);
BarChartWidget::new(bars())
.max_value(9)
.bar_width(1)
.bar_gap(0)
.show_values(false)
.render(area, &mut without_values);
assert_eq!(with_values.cell((0, 1)).expect("value cell").symbol(), "9");
assert_eq!(without_values.cell((0, 1)).expect("bar cell").symbol(), "█");
}
#[test]
fn vertical_width_measures_exactly_what_paints() {
let chart = || {
BarChartWidget::new(vec![
Bar::default().value(1),
Bar::default().value(1),
Bar::default().value(1),
])
.max_value(1)
.show_values(false)
.bar_width(2)
.bar_gap(1)
};
let width = chart().width();
assert_eq!(width, 8);
let area = Rect::new(0, 0, width, 1);
let mut buffer = Buffer::empty(area);
chart().render(area, &mut buffer);
assert_eq!(
buffer.cell((width - 1, 0)).expect("last bar end").symbol(),
"█"
);
let narrow = Rect::new(0, 0, width - 1, 1);
let mut narrow_buffer = Buffer::empty(narrow);
chart().render(narrow, &mut narrow_buffer);
assert_ne!(
narrow_buffer.cell((width - 2, 0)).expect("cell").symbol(),
"█"
);
}
#[test]
fn grouped_horizontal_height_measures_exactly_what_paints() {
let chart = || {
BarChartWidget::grouped(vec![
BarChartGroup::new(vec![Bar::default().value(1), Bar::default().value(1)]),
BarChartGroup::new(vec![Bar::default().value(1), Bar::default().value(1)]),
])
.direction(Direction::Horizontal)
.max_value(1)
.show_values(false)
.bar_width(1)
.bar_gap(0)
.group_gap(1)
};
let height = chart().height();
assert_eq!(height, 5);
let area = Rect::new(0, 0, 4, height);
let mut buffer = Buffer::empty(area);
chart().render(area, &mut buffer);
assert_eq!(
buffer.cell((3, height - 1)).expect("last bar end").symbol(),
"█"
);
let bar_cells = |buffer: &Buffer| {
buffer
.content()
.iter()
.filter(|cell| cell.symbol() == "█")
.count()
};
let short = Rect::new(0, 0, 4, height - 1);
let mut short_buffer = Buffer::empty(short);
chart().render(short, &mut short_buffer);
assert!(bar_cells(&short_buffer) < bar_cells(&buffer));
}
#[test]
fn show_values_false_hides_grouped_bar_values() {
let area = Rect::new(0, 0, 1, 2);
let mut with_values = Buffer::empty(area);
let mut without_values = Buffer::empty(area);
let group = || BarChartGroup::new(vec![Bar::default().value(9)]);
BarChartWidget::grouped(vec![group()])
.max_value(9)
.bar_width(1)
.bar_gap(0)
.render(area, &mut with_values);
BarChartWidget::grouped(vec![group()])
.max_value(9)
.bar_width(1)
.bar_gap(0)
.show_values(false)
.render(area, &mut without_values);
assert_eq!(with_values.cell((0, 1)).expect("value cell").symbol(), "9");
assert_eq!(without_values.cell((0, 1)).expect("bar cell").symbol(), "█");
}
}