From c24f045fceb47ec5375bd755f376762aa7881a73 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 3 Mar 2025 22:28:26 -0800 Subject: [PATCH] feat: add new tui-bar-graph crate (#63) ![Braille demo](https://vhs.charm.sh/vhs-3H7bFj0M1kj0GoHcc4EIJ4.gif) ![Solid demo](https://vhs.charm.sh/vhs-5XMtSFgX3vqOhKcKl8fEQK.gif) ```rust use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5]; let bar_graph = BarGraph::new(data) .with_gradient(colorgrad::preset::turbo()) .with_bar_style(BarStyle::Braille) .with_color_mode(ColorMode::VerticalGradient); frame.render_widget(bar_graph, area); ``` --- Cargo.toml | 5 + src/lib.rs | 4 + tui-bar-graph/Cargo.toml | 24 ++ tui-bar-graph/README.md | 47 ++++ tui-bar-graph/examples/braille.tape | 14 + tui-bar-graph/examples/solid.tape | 14 + tui-bar-graph/examples/tui-bar-graph.rs | 51 ++++ tui-bar-graph/src/lib.rs | 343 ++++++++++++++++++++++++ 8 files changed, 502 insertions(+) create mode 100644 tui-bar-graph/Cargo.toml create mode 100644 tui-bar-graph/README.md create mode 100644 tui-bar-graph/examples/braille.tape create mode 100644 tui-bar-graph/examples/solid.tape create mode 100644 tui-bar-graph/examples/tui-bar-graph.rs create mode 100644 tui-bar-graph/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index d634175..1b7574c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ itertools = "0.14.0" indoc = "2.0.5" lipsum = "0.9.1" pretty_assertions = "1.4.1" +rand = "0.9.0" ratatui = { version = "0.29.0", default-features = false } ratatui-macros = "0.6.0" rstest = "0.24.0" @@ -58,6 +59,7 @@ rust-version.workspace = true #! # features ## By default, all the widgets are enabled. default = [ + "bar-graph", "big-text", "box-text", "cards", @@ -66,6 +68,8 @@ default = [ "qrcode", "scrollview", ] +## Enables the [`bar_graph`] widget +bar-graph = ["tui-bar-graph"] ## Enables the [`big_text`] widget big-text = ["tui-big-text"] ## Enables the [`box_text`] widget @@ -84,6 +88,7 @@ scrollview = ["tui-scrollview"] [dependencies] document-features.workspace = true ratatui = { workspace = true } +tui-bar-graph = { version = "0.1.0", path = "tui-bar-graph", optional = true } tui-big-text = { version = "0.7.0", path = "tui-big-text", optional = true } tui-box-text = { version = "0.2.0", path = "tui-box-text", optional = true } tui-cards = { version = "0.2.0", path = "tui-cards", optional = true } diff --git a/src/lib.rs b/src/lib.rs index 746ca12..ded95dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ //! //! Includes the following widgets, which are each also available as standalone crates: //! +//! - [tui-bar-graph](https://crates.io/crates/tui-bar-graph) //! - [tui-big-text](https://crates.io/crates/tui-big-text) //! - [tui-box-text](https://crates.io/crates/tui-box-text) //! - [tui-cards](https://crates.io/crates/tui-cards) @@ -16,6 +17,9 @@ //! - [tui-scrollview](https://crates.io/crates/tui-scrollview) #![doc = document_features::document_features!()] +#[cfg(feature = "bar-graph")] +#[doc(inline)] +pub use tui_bar_graph as bar_graph; #[cfg(feature = "big-text")] #[doc(inline)] pub use tui_big_text as big_text; diff --git a/tui-bar-graph/Cargo.toml b/tui-bar-graph/Cargo.toml new file mode 100644 index 0000000..40c739a --- /dev/null +++ b/tui-bar-graph/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tui-bar-graph" +description = "A Ratatui widget for rendering pretty bar graphs in the terminal" +version = "0.1.0" +documentation = "https://docs.rs/tui-bar-graph" +authors.workspace = true +license.workspace = true +repository.workspace = true +edition.workspace = true +rust-version.workspace = true +categories.workspace = true +keywords.workspace = true + +[dependencies] +colorgrad = "0.7.0" +ratatui.workspace = true +strum.workspace = true + +[dev-dependencies] +clap.workspace = true +color-eyre.workspace = true +crossterm.workspace = true +rand.workspace = true +ratatui = { workspace = true, default-features = true } diff --git a/tui-bar-graph/README.md b/tui-bar-graph/README.md new file mode 100644 index 0000000..5a8ed42 --- /dev/null +++ b/tui-bar-graph/README.md @@ -0,0 +1,47 @@ +# Tui-bar-graph + + + +A [Ratatui] widget for displaying pretty bar graphs + +Uses the [Colorgrad] crate for gradient coloring. + +![Braille demo](https://vhs.charm.sh/vhs-3H7bFj0M1kj0GoHcc4EIJ4.gif) + +![Solid demo](https://vhs.charm.sh/vhs-5XMtSFgX3vqOhKcKl8fEQK.gif) + +[![Crate badge]][Crate] +[![Docs Badge]][Docs] +[![License Badge]](./LICENSE-MIT) +[![Discord Badge]][Discord] + +## Installation + +```shell +cargo add ratatui tui-bar-graph +``` + +## Example + +```rust +use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; + +let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5]; +let bar_graph = BarGraph::new(data) + .with_gradient(colorgrad::preset::turbo()) + .with_bar_style(BarStyle::Braille) + .with_color_mode(ColorMode::VerticalGradient); +frame.render_widget(bar_graph, area); +``` + +[Colorgrad]: https://crates.io/crates/colorgrad +[Ratatui]: https://crates.io/crates/ratatui +[Crate]: https://crates.io/crates/tui-bar-graph +[Docs]: https://docs.rs/tui-bar-graph +[Discord]: https://discord.gg/pMCEU9hNEj +[Crate badge]: https://img.shields.io/crates/v/tui-bar-graph.svg?logo=rust&style=for-the-badge +[Docs Badge]: https://img.shields.io/docsrs/tui-bar-graph?logo=rust&style=for-the-badge +[License Badge]: https://img.shields.io/crates/l/tui-bar-graph.svg?style=for-the-badge +[Discord Badge]: https://img.shields.io/discord/1070692720437383208?label=ratatui+discord&logo=discord&style=for-the-badge + + diff --git a/tui-bar-graph/examples/braille.tape b/tui-bar-graph/examples/braille.tape new file mode 100644 index 0000000..356f6e5 --- /dev/null +++ b/tui-bar-graph/examples/braille.tape @@ -0,0 +1,14 @@ +# VHS Tape (see https://github.com/charmbracelet/vhs) +Output "target/tui-bar-graph-braille.gif" +Set Theme "Aardvark Blue" +Set Width 1200 +Set Height 800 +Hide +Type@0 "cargo run --quiet -p tui-bar-graph --example tui-bar-graph braille" +Enter +Sleep 2s +Show +Screenshot "target/tui-bar-graph-braille.png" +Sleep 1s +Hide +Escape \ No newline at end of file diff --git a/tui-bar-graph/examples/solid.tape b/tui-bar-graph/examples/solid.tape new file mode 100644 index 0000000..8996696 --- /dev/null +++ b/tui-bar-graph/examples/solid.tape @@ -0,0 +1,14 @@ +# VHS Tape (see https://github.com/charmbracelet/vhs) +Output "target/tui-bar-graph-solid.gif" +Set Theme "Aardvark Blue" +Set Width 1200 +Set Height 800 +Hide +Type@0 "cargo run --quiet -p tui-bar-graph --example tui-bar-graph solid" +Enter +Sleep 2s +Show +Screenshot "target/tui-bar-graph-solid.png" +Sleep 1s +Hide +Escape \ No newline at end of file diff --git a/tui-bar-graph/examples/tui-bar-graph.rs b/tui-bar-graph/examples/tui-bar-graph.rs new file mode 100644 index 0000000..009488c --- /dev/null +++ b/tui-bar-graph/examples/tui-bar-graph.rs @@ -0,0 +1,51 @@ +use clap::Parser; +use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; +use rand::Rng; +use ratatui::{DefaultTerminal, Frame}; +use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; + +#[derive(Debug, Parser)] +struct Args { + /// The style of bar to render (solid or braille) + #[arg(default_value_t = BarStyle::Braille)] + bar_style: BarStyle, +} + +fn main() -> color_eyre::Result<()> { + let args = Args::parse(); + color_eyre::install()?; + let terminal = ratatui::init(); + let result = run(terminal, &args); + ratatui::restore(); + result +} + +fn run(mut terminal: DefaultTerminal, args: &Args) -> color_eyre::Result<()> { + loop { + terminal.draw(|frame| render(frame, args))?; + if matches!( + event::read()?, + Event::Key(KeyEvent { + kind: KeyEventKind::Press, + .. + }) + ) { + break Ok(()); + } + } +} + +fn render(frame: &mut Frame, args: &Args) { + let width = match args.bar_style { + BarStyle::Solid => frame.area().width as usize, + BarStyle::Braille => frame.area().width as usize * 2, + }; + let mut data = vec![0.0; width]; + rand::rng().fill(&mut data[..]); + + let bar_graph = BarGraph::new(data) + .with_gradient(colorgrad::preset::turbo()) + .with_color_mode(ColorMode::VerticalGradient) + .with_bar_style(args.bar_style); + frame.render_widget(bar_graph, frame.area()); +} diff --git a/tui-bar-graph/src/lib.rs b/tui-bar-graph/src/lib.rs new file mode 100644 index 0000000..61d48b2 --- /dev/null +++ b/tui-bar-graph/src/lib.rs @@ -0,0 +1,343 @@ +//! A [Ratatui] widget for displaying pretty bar graphs +//! +//! Uses the [Colorgrad] crate for gradient coloring. +//! +//! ![Braille demo](https://vhs.charm.sh/vhs-3H7bFj0M1kj0GoHcc4EIJ4.gif) +//! +//! ![Solid demo](https://vhs.charm.sh/vhs-5XMtSFgX3vqOhKcKl8fEQK.gif) +//! +//! [![Crate badge]][Crate] +//! [![Docs Badge]][Docs] +//! [![License Badge]](./LICENSE-MIT) +//! [![Discord Badge]][Discord] +//! +//! # Installation +//! +//! ```shell +//! cargo add ratatui tui-bar-graph +//! ``` +//! +//! # Example +//! +//! ```rust +//! use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; +//! +//! # fn render(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) { +//! let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5]; +//! let bar_graph = BarGraph::new(data) +//! .with_gradient(colorgrad::preset::turbo()) +//! .with_bar_style(BarStyle::Braille) +//! .with_color_mode(ColorMode::VerticalGradient); +//! frame.render_widget(bar_graph, area); +//! # } +//! ``` +//! +//! [Colorgrad]: https://crates.io/crates/colorgrad +//! [Ratatui]: https://crates.io/crates/ratatui +//! [Crate]: https://crates.io/crates/tui-bar-graph +//! [Docs]: https://docs.rs/tui-bar-graph +//! [Discord]: https://discord.gg/pMCEU9hNEj +//! [Crate badge]: https://img.shields.io/crates/v/tui-bar-graph.svg?logo=rust&style=for-the-badge +//! [Docs Badge]: https://img.shields.io/docsrs/tui-bar-graph?logo=rust&style=for-the-badge +//! [License Badge]: https://img.shields.io/crates/l/tui-bar-graph.svg?style=for-the-badge +//! [Discord Badge]: https://img.shields.io/discord/1070692720437383208?label=ratatui+discord&logo=discord&style=for-the-badge + +use colorgrad::Gradient; +use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget}; +use strum::{Display, EnumString}; + +// Each side (left/right) has 5 possible heights (0-4) +const BRAILLE_PATTERNS: [[&str; 5]; 5] = [ + // Right height 0-4 (columns) for left height 0 (row) + ["⠀", "⢀", "⢠", "⢰", "⢸"], + // Right height 0-4 (columns) for left height 1 (row) + ["⡀", "⣀", "⣠", "⣰", "⣸"], + // Right height 0-4 (columns) for left height 2 (row) + ["⡄", "⣄", "⣤", "⣴", "⣼"], + // Right height 0-4 (columns) for left height 3 (row) + ["⡆", "⣆", "⣦", "⣶", "⣾"], + // Right height 0-4 (columns) for left height 4 (row) + ["⡇", "⣇", "⣧", "⣷", "⣿"], +]; + +/// A widget for displaying a bar graph. +/// +/// The bars can be colored using a gradient, and can be rendered using either solid blocks or +/// braille characters for a more granular appearance. +/// +/// # Example +/// +/// ```rust +/// use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; +/// +/// # fn render(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) { +/// let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5]; +/// let bar_graph = BarGraph::new(data) +/// .with_gradient(colorgrad::preset::turbo()) +/// .with_bar_style(BarStyle::Braille) +/// .with_color_mode(ColorMode::VerticalGradient); +/// frame.render_widget(bar_graph, area); +/// # } +/// ``` +pub struct BarGraph { + /// The data to display as bars. + data: Vec, + + /// The maximum value to display. + max: Option, + + /// The minimum value to display. + min: Option, + + /// A gradient to use for coloring the bars. + gradient: Option>, + + /// The direction of the gradient coloring. + color_mode: ColorMode, + + /// The style of bar to render. + bar_style: BarStyle, +} + +/// The direction of the gradient coloring. +/// +/// - `Solid`: Each bar has a single color based on its value. +/// - `Gradient`: Each bar is gradient-colored from bottom to top. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColorMode { + /// Each bar has a single color based on its value. + Solid, + /// Each bar is gradient-colored from bottom to top. + #[default] + VerticalGradient, +} + +/// The style of bar to render. +/// +/// - `Solid`: Render bars using the full block character '█'. +/// - `Braille`: Render bars using braille characters for a more granular appearance. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, EnumString, Display)] +#[strum(serialize_all = "snake_case")] +pub enum BarStyle { + /// Render bars using the full block character '█'. + Solid, + /// Render bars using braille characters for more granular representation. + #[default] + Braille, +} + +impl BarGraph { + /// Creates a new bar graph with the given data. + pub fn new(data: Vec) -> Self { + Self { + data, + max: None, + min: None, + gradient: None, + color_mode: ColorMode::default(), + bar_style: BarStyle::default(), + } + } + + /// Sets the gradient to use for coloring the bars. + /// + /// If `None`, the bars are colored with the default foreground color. + /// + /// See the [colorgrad] crate for information on creating gradients. Note that the default + /// domain (range) of the gradient is [0, 1], so you may need to scale your data to fit this + /// range, or modify the gradient's domain to fit your data. + pub fn with_gradient(mut self, gradient: T) -> Self { + self.gradient = Some(Box::new(gradient)); + self + } + + /// Sets the maximum value to display. + /// + /// Values greater than this will be clamped to this value. If `None`, the maximum value is + /// calculated from the data. + pub fn with_max(mut self, max: impl Into>) -> Self { + self.max = max.into(); + self + } + + /// Sets the minimum value to display. + /// + /// Values less than this will be clamped to this value. If `None`, the minimum value is + /// calculated from the data. + pub fn with_min(mut self, min: impl Into>) -> Self { + self.min = min.into(); + self + } + + /// Sets the color mode for the bars. + /// + /// The default is `ColorMode::VerticalGradient`. + /// + /// - `Solid`: Each bar has a single color based on its value. + /// - `Gradient`: Each bar is gradient-colored from bottom to top. + pub fn with_color_mode(mut self, color: ColorMode) -> Self { + self.color_mode = color; + self + } + + /// Sets the style of the bars. + /// + /// The default is `BarStyle::Braille`. + /// + /// - `Solid`: Render bars using the full block character '█'. + /// - `Braille`: Render bars using braille characters for more granular representation. + pub fn with_bar_style(mut self, style: BarStyle) -> Self { + self.bar_style = style; + self + } + + /// Gets the color of a bar based on its value. + fn color(&self, value: f64) -> Color { + if let Some(gradient) = &self.gradient { + let color = gradient.at(value as f32); + to_ratatui_color(&color) + } else { + Color::Reset + } + } + + /// Renders the graph using solid blocks (█). + fn render_solid(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) { + let range = max - min; + + for (i, &value) in self.data.iter().enumerate() { + if i == area.width as usize { + break; + } + let normalized = (value - min) / range; + let height = (normalized * area.height as f64).round() as u16; + for y in 0..area.height { + if y < height { + let cell = &mut buf[(area.left() + i as u16, area.bottom() - y - 1)]; + cell.set_symbol("█"); + + let color_value = match self.color_mode { + ColorMode::Solid => value, + ColorMode::VerticalGradient => { + // For gradient coloring, we calculate the color based on the y position + // within the bar (bottom to top gradient) + let y_normalized = y as f64 / area.height as f64; + min + y_normalized * range + } + }; + cell.set_fg(self.color(color_value)); + } + } + } + } + + /// Renders the graph using braille characters. + fn render_braille(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) { + // Each braille character represents 4 vertical dots + const DOTS_PER_ROW: usize = 4; + + let range = max - min; + let row_count = area.height; + let total_dots = row_count as usize * DOTS_PER_ROW; + + for (i, chunk) in self.data.chunks(2).enumerate() { + if i >= area.width as usize { + break; + } + let left_value = chunk.get(0).cloned().unwrap_or(min); + let right_value = chunk.get(1).cloned().unwrap_or(min); + + let left_normalized = ((left_value - min) / range).clamp(0.0, 1.0); + let right_normalized = ((right_value - min) / range).clamp(0.0, 1.0); + + // Calculate total height in dots (scaled to fill the entire height) + 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; + + // Render each row of braille characters + for row in 0..row_count { + // Calculate row position (from bottom to top) + let y_pos = area.bottom() - 1 - row; + + // Calculate the base dot index for this row (counting from bottom) + let row_base = row * DOTS_PER_ROW as u16; + + // Calculate dots for this braille character + let left_height = (left_total_dots as u16).saturating_sub(row_base).min(4) as usize; + let right_height = + (right_total_dots as u16).saturating_sub(row_base).min(4) as usize; + + // Place the braille character + let symbol = BRAILLE_PATTERNS[left_height][right_height]; + let cell = &mut buf[(area.left() + i as u16, y_pos)]; + cell.set_symbol(symbol); + + let color_value = match self.color_mode { + ColorMode::Solid => { + // Use the average of the two values for coloring + (left_value + right_value) / 2.0 + } + ColorMode::VerticalGradient => { + // For gradient coloring, calculate position in the overall graph + let row_normalized = row as f64 / row_count as f64; + min + row_normalized * range + } + }; + cell.set_fg(self.color(color_value)); + } + } + } +} + +impl Widget for BarGraph { + fn render(self, area: Rect, buf: &mut Buffer) { + // f64 doesn't impl Ord because NaN != NaN, so we can't use iter::max/min + let max = self + .max + .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max)); + let min = self + .min + .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min)); + + match self.bar_style { + BarStyle::Solid => self.render_solid(area, buf, min, max), + BarStyle::Braille => self.render_braille(area, buf, min, max), + } + } +} + +/// Converts a colorgrad color to a ratatui color. +fn to_ratatui_color(color: &colorgrad::Color) -> Color { + let rgba = color.to_rgba8(); + Color::Rgb(rgba[0], rgba[1], rgba[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let bar_graph = BarGraph::new(data); + + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10)); + bar_graph.render(Rect::new(0, 0, 10, 10), &mut buf); + + assert_eq!( + buf, + Buffer::with_lines(vec![ + "⠀⠀⡇ ", + "⠀⠀⡇ ", + "⠀⢠⡇ ", + "⠀⢸⡇ ", + "⠀⢸⡇ ", + "⠀⣿⡇ ", + "⠀⣿⡇ ", + "⢠⣿⡇ ", + "⢸⣿⡇ ", + "⢸⣿⡇ ", + ]) + ); + } +}