#![cfg_attr(docsrs, doc = "\n# Feature flags\n")]
#![cfg_attr(docsrs, doc = document_features::document_features!())]
use colorgrad::Gradient;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Color;
use ratatui_core::widgets::Widget;
use strum::{Display, EnumString};
const BRAILLE_PATTERNS: [[&str; 5]; 5] = [
["⠀", "⢀", "⢠", "⢰", "⢸"],
["⡀", "⣀", "⣠", "⣰", "⣸"],
["⡄", "⣄", "⣤", "⣴", "⣼"],
["⡆", "⣆", "⣦", "⣶", "⣾"],
["⡇", "⣇", "⣧", "⣷", "⣿"],
];
const OCTANT_PATTERNS: [[&str; 5]; 5] = [
["⠀", "", "▗", "", "▐"],
["", "▂", "", "", ""],
["▖", "", "▄", "", "▟"],
["", "", "", "▆", ""],
["▌", "", "▙", "", "█"],
];
#[rustfmt::skip]
const QUADRANT_PATTERNS: [[&str; 3]; 3]= [
[" ", "▗", "▐"],
["▖", "▄", "▟"],
["▌", "▙", "█"],
];
pub struct BarGraph<'g> {
data: Vec<f64>,
max: Option<f64>,
min: Option<f64>,
gradient: Option<Box<dyn Gradient + 'g>>,
color_mode: ColorMode,
bar_style: BarStyle,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorMode {
Solid,
#[default]
VerticalGradient,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, EnumString, Display)]
#[strum(serialize_all = "snake_case")]
pub enum BarStyle {
#[default]
Braille,
Solid,
Quadrant,
Octant,
}
impl<'g> BarGraph<'g> {
pub fn new(data: Vec<f64>) -> Self {
Self {
data,
max: None,
min: None,
gradient: None,
color_mode: ColorMode::default(),
bar_style: BarStyle::default(),
}
}
pub fn with_gradient(mut self, gradient: impl Gradient + 'g) -> Self {
self.gradient = Some(gradient.boxed());
self
}
pub fn with_max(mut self, max: impl Into<Option<f64>>) -> Self {
self.max = max.into();
self
}
pub fn with_min(mut self, min: impl Into<Option<f64>>) -> Self {
self.min = min.into();
self
}
pub const fn with_color_mode(mut self, color: ColorMode) -> Self {
self.color_mode = color;
self
}
pub const fn with_bar_style(mut self, style: BarStyle) -> Self {
self.bar_style = style;
self
}
fn render_solid(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
let range = max - min;
for (&value, column) in self.data.iter().zip(area.columns()) {
let normalized = (value - min) / range;
let column_height = (normalized * area.height as f64).ceil() as usize;
for (i, row) in column.rows().rev().enumerate().take(column_height) {
let color = self.color_for(area, min, range, value, i);
buf[row].set_symbol("█").set_fg(color);
}
}
}
fn render_braille(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
self.render_pattern(area, buf, min, max, 4, &BRAILLE_PATTERNS);
}
fn render_octant(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
self.render_pattern(area, buf, min, max, 4, &OCTANT_PATTERNS);
}
fn render_quadrant(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
self.render_pattern(area, buf, min, max, 2, &QUADRANT_PATTERNS);
}
fn render_pattern<const N: usize, const M: usize>(
&self,
area: Rect,
buf: &mut Buffer,
min: f64,
max: f64,
dots_per_row: usize,
patterns: &[[&str; N]; M],
) {
let range = max - min;
let row_count = area.height;
let total_dots = row_count as usize * dots_per_row;
for (chunk, column) in self
.data
.chunks(2)
.zip(area.columns())
.take(area.width as usize)
{
let left_value = chunk[0];
let right_value = chunk.get(1).copied().unwrap_or(min);
let left_normalized = (left_value - min) / range;
let right_normalized = (right_value - min) / range;
let left_total_dots = (left_normalized * total_dots as f64).round() as usize;
let right_total_dots = (right_normalized * total_dots as f64).round() as usize;
let column_height = (left_total_dots.max(right_total_dots) as f64 / dots_per_row as f64)
.ceil() as usize;
for (row_index, row) in column.rows().rev().enumerate().take(column_height) {
let value = f64::midpoint(left_value, right_value);
let color = self.color_for(area, min, max, value, row_index);
let dots_below = row_index * dots_per_row;
let left_dots = left_total_dots.saturating_sub(dots_below).min(dots_per_row);
let right_dots = right_total_dots
.saturating_sub(dots_below)
.min(dots_per_row);
let symbol = patterns[left_dots][right_dots];
buf[row].set_symbol(symbol).set_fg(color);
}
}
}
fn color_for(&self, area: Rect, min: f64, max: f64, value: f64, row: usize) -> Color {
let color_value = match self.color_mode {
ColorMode::Solid => value,
ColorMode::VerticalGradient => {
(row as f64 / area.height as f64).mul_add(max - min, min)
}
};
self.gradient
.as_ref()
.map(|gradient| {
let color = gradient.at(color_value as f32);
let rgba = color.to_rgba8();
Color::Rgb(rgba[0], rgba[1], rgba[2])
})
.unwrap_or(Color::Reset)
}
}
impl Widget for BarGraph<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let min = self
.min
.unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
let max = self
.max
.unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
let max = max.max(min + f64::EPSILON); match self.bar_style {
BarStyle::Braille => self.render_braille(area, buf, min, max),
BarStyle::Solid => self.render_solid(area, buf, min, max),
BarStyle::Quadrant => self.render_quadrant(area, buf, min, max),
BarStyle::Octant => self.render_octant(area, buf, min, max),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_gradient() {
let data = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
let _graph = BarGraph::new(data.clone()).with_gradient(colorgrad::preset::turbo());
let _graph = BarGraph::new(data).with_gradient(colorgrad::preset::turbo().boxed());
}
#[test]
fn braille() {
let data = (0..=40).map(|i| i as f64 * 0.125).collect();
let bar_graph = BarGraph::new(data);
let mut buf = Buffer::empty(Rect::new(0, 0, 21, 10));
bar_graph.render(buf.area, &mut buf);
assert_eq!(
buf,
Buffer::with_lines(vec![
" ⢀⣴⡇",
" ⢀⣴⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
"⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
])
);
}
#[test]
fn solid() {
let data = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Solid);
let mut buf = Buffer::empty(Rect::new(0, 0, 11, 10));
bar_graph.render(buf.area, &mut buf);
assert_eq!(
buf,
Buffer::with_lines(vec![
" █",
" ██",
" ███",
" ████",
" █████",
" ██████",
" ███████",
" ████████",
" █████████",
" ██████████",
])
);
}
#[test]
fn quadrant() {
let data = vec![
0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75,
4.0, 4.25, 4.5, 4.75, 5.0,
];
let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Quadrant);
let mut buf = Buffer::empty(Rect::new(0, 0, 11, 10));
bar_graph.render(buf.area, &mut buf);
assert_eq!(
buf,
Buffer::with_lines(vec![
" ▗▌",
" ▗█▌",
" ▗██▌",
" ▗███▌",
" ▗████▌",
" ▗█████▌",
" ▗██████▌",
" ▗███████▌",
" ▗████████▌",
"▗█████████▌",
])
);
}
#[test]
fn octant() {
let data = (0..=40).map(|i| i as f64 * 0.125).collect();
let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Octant);
let mut buf = Buffer::empty(Rect::new(0, 0, 21, 10));
bar_graph.render(buf.area, &mut buf);
assert_eq!(
buf,
Buffer::with_lines(vec![
" ▌",
" ██▌",
" ████▌",
" ██████▌",
" ████████▌",
" ██████████▌",
" ████████████▌",
" ██████████████▌",
" ████████████████▌",
"██████████████████▌",
])
);
}
}