tui_bar_graph/
lib.rs

1//! A [Ratatui] widget to render bold, colorful bar graphs. Part of the [tui-widgets] suite by
2//! [Joshka].
3//!
4//! ![Braille Rainbow](https://vhs.charm.sh/vhs-1sx9Ht6NzU6e28Cl51jJVv.gif)
5//! ![Solid Plasma](https://vhs.charm.sh/vhs-7pWuLtZpzrz1OVD04cMt1a.gif)
6//! ![Quadrant Magma](https://vhs.charm.sh/vhs-1rx6XQ9mLiO8qybSBXRGwn.gif)
7//! ![Octant Viridis](https://vhs.charm.sh/vhs-7BevtFvn5S7j8jcAJrxl1F.gif)
8//!
9//! <details><summary>More examples</summary>
10//!
11//! ![Braille Magma](https://vhs.charm.sh/vhs-4RDwcz9DApA90iJYMQXHXd.gif)
12//! ![Braille Viridis](https://vhs.charm.sh/vhs-5ylsZAdKGPiHUYboOpZFZL.gif)
13//! ![Solid Inferno](https://vhs.charm.sh/vhs-4z1gbmJ50KGz2TPej3mnVf.gif)
14//! ![Solid Sinebow](https://vhs.charm.sh/vhs-63aAmMhcfMT8CnWCV20dsn.gif)
15//! ![Quadrant Plasma](https://vhs.charm.sh/vhs-5o8AfNgQZAT1U4hOaLtY7m.gif)
16//! ![Quadrant Sinebow](https://vhs.charm.sh/vhs-1zAyLkSvNGTKL1SGHRyZFD.gif)
17//! ![Octant Inferno](https://vhs.charm.sh/vhs-3bwxZkh1WcSFUkVzpBXWb9.gif)
18//! ![Octant Rainbow](https://vhs.charm.sh/vhs-6eDjdEbRK4xWNtVpHuTkIh.gif)
19//!
20//! </details>
21//!
22//! Uses the [Colorgrad] crate for gradient coloring.
23//!
24//! [![Crate badge]][Crate]
25//! [![Docs Badge]][Docs]
26//! [![Deps Badge]][Dependency Status]
27//! [![License Badge]][License]
28//! [![Coverage Badge]][Coverage]
29//! [![Discord Badge]][Ratatui Discord]
30//!
31//! [GitHub Repository] · [API Docs] · [Examples] · [Changelog] · [Contributing]
32//!
33//! # Installation
34//!
35//! ```shell
36//! cargo add ratatui tui-bar-graph
37//! ```
38//!
39//! # Usage
40//!
41//! Build a `BarGraph` with your data and render it in a widget area.
42//!
43//! ```rust
44//! use tui_bar_graph::{BarGraph, BarStyle, ColorMode};
45//!
46//! # fn render(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
47//! let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
48//! let bar_graph = BarGraph::new(data)
49//!     .with_gradient(colorgrad::preset::turbo())
50//!     .with_bar_style(BarStyle::Braille)
51//!     .with_color_mode(ColorMode::VerticalGradient);
52//! frame.render_widget(bar_graph, area);
53//! # }
54//! ```
55//!
56//! # More widgets
57//!
58//! For the full suite of widgets, see [tui-widgets].
59//!
60//! [Colorgrad]: https://crates.io/crates/colorgrad
61//! [Ratatui]: https://crates.io/crates/ratatui
62//! [Crate]: https://crates.io/crates/tui-bar-graph
63//! [Docs]: https://docs.rs/tui-bar-graph/
64//! [Dependency Status]: https://deps.rs/repo/github/joshka/tui-widgets
65//! [Coverage]: https://app.codecov.io/gh/joshka/tui-widgets
66//! [Ratatui Discord]: https://discord.gg/pMCEU9hNEj
67//! [Crate badge]: https://img.shields.io/crates/v/tui-bar-graph.svg?logo=rust&style=flat
68//! [Docs Badge]: https://img.shields.io/docsrs/tui-bar-graph?logo=rust&style=flat
69//! [Deps Badge]: https://deps.rs/repo/github/joshka/tui-widgets/status.svg?style=flat
70//! [License Badge]: https://img.shields.io/crates/l/tui-bar-graph.svg?style=flat
71//! [License]: https://github.com/joshka/tui-widgets/blob/main/LICENSE-MIT
72//! [Coverage Badge]:
73//!     https://img.shields.io/codecov/c/github/joshka/tui-widgets?logo=codecov&style=flat
74//! [Discord Badge]: https://img.shields.io/discord/1070692720437383208?logo=discord&style=flat
75//!
76//! [GitHub Repository]: https://github.com/joshka/tui-widgets
77//! [API Docs]: https://docs.rs/tui-bar-graph/
78//! [Examples]: https://github.com/joshka/tui-widgets/tree/main/tui-bar-graph/examples
79//! [Changelog]: https://github.com/joshka/tui-widgets/blob/main/tui-bar-graph/CHANGELOG.md
80//! [Contributing]: https://github.com/joshka/tui-widgets/blob/main/CONTRIBUTING.md
81//!
82//! [Joshka]: https://github.com/joshka
83//! [tui-widgets]: https://crates.io/crates/tui-widgets
84
85use colorgrad::Gradient;
86use ratatui_core::buffer::Buffer;
87use ratatui_core::layout::Rect;
88use ratatui_core::style::Color;
89use ratatui_core::widgets::Widget;
90use strum::{Display, EnumString};
91
92const BRAILLE_PATTERNS: [[&str; 5]; 5] = [
93    ["⠀", "⢀", "⢠", "⢰", "⢸"],
94    ["⡀", "⣀", "⣠", "⣰", "⣸"],
95    ["⡄", "⣄", "⣤", "⣴", "⣼"],
96    ["⡆", "⣆", "⣦", "⣶", "⣾"],
97    ["⡇", "⣇", "⣧", "⣷", "⣿"],
98];
99
100const OCTANT_PATTERNS: [[&str; 5]; 5] = [
101    ["⠀", "𜺠", "▗", "𜶖", "▐"],
102    ["𜺣", "▂", "𜷋", "𜷓", "𜷕"],
103    ["▖", "𜶻", "▄", "𜷡", "▟"],
104    ["𜵈", "𜶿", "𜷞", "▆", "𜷥"],
105    ["▌", "𜷀", "▙", "𜷤", "█"],
106];
107
108#[rustfmt::skip]
109const QUADRANT_PATTERNS: [[&str; 3]; 3]= [
110    [" ", "▗", "▐"],
111    ["▖", "▄", "▟"],
112    ["▌", "▙", "█"],
113];
114
115/// A widget for displaying a bar graph.
116///
117/// The bars can be colored using a gradient, and can be rendered using either solid blocks or
118/// braille characters for a more granular appearance.
119///
120/// # Example
121///
122/// ```rust
123/// use tui_bar_graph::{BarGraph, BarStyle, ColorMode};
124///
125/// # fn render(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
126/// let data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
127/// let bar_graph = BarGraph::new(data)
128///     .with_gradient(colorgrad::preset::turbo())
129///     .with_bar_style(BarStyle::Braille)
130///     .with_color_mode(ColorMode::VerticalGradient);
131/// frame.render_widget(bar_graph, area);
132/// # }
133/// ```
134pub struct BarGraph<'g> {
135    /// The data to display as bars.
136    data: Vec<f64>,
137
138    /// The maximum value to display.
139    max: Option<f64>,
140
141    /// The minimum value to display.
142    min: Option<f64>,
143
144    /// A gradient to use for coloring the bars.
145    gradient: Option<Box<dyn Gradient + 'g>>,
146
147    /// The direction of the gradient coloring.
148    color_mode: ColorMode,
149
150    /// The style of bar to render.
151    bar_style: BarStyle,
152}
153
154/// The direction of the gradient coloring.
155///
156/// - `Solid`: Each bar has a single color based on its value.
157/// - `Gradient`: Each bar is gradient-colored from bottom to top.
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
159pub enum ColorMode {
160    /// Each bar has a single color based on its value.
161    Solid,
162    /// Each bar is gradient-colored from bottom to top.
163    #[default]
164    VerticalGradient,
165}
166
167/// The style of bar to render.
168///
169/// - `Solid`: Render bars using the full block character '`█`'.
170/// - `Quadrant`: Render bars using quadrant block characters for a more granular representation.
171/// - `Octant`: Render bars using octant block characters for a more granular representation.
172/// - `Braille`: Render bars using braille characters for a more granular representation.
173///
174/// `Octant` and `Braille` offer the same level of granularity, but `Braille` is more widely
175/// supported by fonts.
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, EnumString, Display)]
177#[strum(serialize_all = "snake_case")]
178pub enum BarStyle {
179    /// Render bars using braille characters `⡀`, `⢀`, `⠄`, `⠠`, `⠂`, `⠐`, `⠁`, and `⠈` for a more
180    /// granular representation.
181    #[default]
182    Braille,
183    /// Render bars using the full block character '`█`'.
184    Solid,
185    /// Render bars using the quadrant block characters `▖`, `▗`, `▘`, and `▝` for a more granular
186    /// representation.
187    Quadrant,
188    /// Render bars using the octant block characters `𜺣`, `𜺠`, `𜴉`, `𜴘`, `𜴀`, `𜴃`, `𜺨`, and `𜺫`
189    /// for a more granular representation.
190    ///
191    /// `Octant` uses characters from the [Symbols for Legacy Computing Supplement] block, which
192    /// is rendered correctly by a small but growing number of fonts.
193    ///
194    /// [Symbols for Legacy Computing Supplement]:
195    ///     https://en.wikipedia.org/wiki/Symbols_for_Legacy_Computing_Supplement
196    Octant,
197}
198
199impl<'g> BarGraph<'g> {
200    /// Creates a new bar graph with the given data.
201    pub fn new(data: Vec<f64>) -> Self {
202        Self {
203            data,
204            max: None,
205            min: None,
206            gradient: None,
207            color_mode: ColorMode::default(),
208            bar_style: BarStyle::default(),
209        }
210    }
211
212    /// Sets the gradient to use for coloring the bars.
213    ///
214    /// See the [colorgrad] crate for information on creating gradients. Note that the default
215    /// domain (range) of the gradient is [0, 1], so you may need to scale your data to fit this
216    /// range, or modify the gradient's domain to fit your data.
217    pub fn with_gradient(mut self, gradient: impl Gradient + 'g) -> Self {
218        self.gradient = Some(gradient.boxed());
219        self
220    }
221
222    /// Sets the maximum value to display.
223    ///
224    /// Values greater than this will be clamped to this value. If `None`, the maximum value is
225    /// calculated from the data.
226    pub fn with_max(mut self, max: impl Into<Option<f64>>) -> Self {
227        self.max = max.into();
228        self
229    }
230
231    /// Sets the minimum value to display.
232    ///
233    /// Values less than this will be clamped to this value. If `None`, the minimum value is
234    /// calculated from the data.
235    pub fn with_min(mut self, min: impl Into<Option<f64>>) -> Self {
236        self.min = min.into();
237        self
238    }
239
240    /// Sets the color mode for the bars.
241    ///
242    /// The default is `ColorMode::VerticalGradient`.
243    ///
244    /// - `Solid`: Each bar has a single color based on its value.
245    /// - `Gradient`: Each bar is gradient-colored from bottom to top.
246    pub const fn with_color_mode(mut self, color: ColorMode) -> Self {
247        self.color_mode = color;
248        self
249    }
250
251    /// Sets the style of the bars.
252    ///
253    /// The default is `BarStyle::Braille`.
254    ///
255    /// - `Solid`: Render bars using the full block character '`█`'.
256    /// - `Quadrant`: Render bars using quadrant block characters for a more granular
257    ///   representation.
258    /// - `Octant`: Render bars using octant block characters for a more granular representation.
259    /// - `Braille`: Render bars using braille characters for a more granular representation.
260    ///
261    /// `Octant` and `Braille` offer the same level of granularity, but `Braille` is more widely
262    /// supported by fonts.
263    pub const fn with_bar_style(mut self, style: BarStyle) -> Self {
264        self.bar_style = style;
265        self
266    }
267
268    /// Renders the graph using solid blocks (█).
269    fn render_solid(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
270        let range = max - min;
271        for (&value, column) in self.data.iter().zip(area.columns()) {
272            let normalized = (value - min) / range;
273            let column_height = (normalized * area.height as f64).ceil() as usize;
274            for (i, row) in column.rows().rev().enumerate().take(column_height) {
275                let color = self.color_for(area, min, range, value, i);
276                buf[row].set_symbol("█").set_fg(color);
277            }
278        }
279    }
280
281    /// Renders the graph using braille characters.
282    fn render_braille(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
283        self.render_pattern(area, buf, min, max, 4, &BRAILLE_PATTERNS);
284    }
285
286    /// Renders the graph using octant blocks.
287    fn render_octant(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
288        self.render_pattern(area, buf, min, max, 4, &OCTANT_PATTERNS);
289    }
290
291    /// Renders the graph using quadrant blocks.
292    fn render_quadrant(&self, area: Rect, buf: &mut Buffer, min: f64, max: f64) {
293        self.render_pattern(area, buf, min, max, 2, &QUADRANT_PATTERNS);
294    }
295
296    /// Common rendering logic for pattern-based bar styles.
297    fn render_pattern<const N: usize, const M: usize>(
298        &self,
299        area: Rect,
300        buf: &mut Buffer,
301        min: f64,
302        max: f64,
303        dots_per_row: usize,
304        patterns: &[[&str; N]; M],
305    ) {
306        let range = max - min;
307        let row_count = area.height;
308        let total_dots = row_count as usize * dots_per_row;
309
310        for (chunk, column) in self
311            .data
312            .chunks(2)
313            .zip(area.columns())
314            .take(area.width as usize)
315        {
316            let left_value = chunk[0];
317            let right_value = chunk.get(1).copied().unwrap_or(min);
318
319            let left_normalized = (left_value - min) / range;
320            let right_normalized = (right_value - min) / range;
321
322            let left_total_dots = (left_normalized * total_dots as f64).round() as usize;
323            let right_total_dots = (right_normalized * total_dots as f64).round() as usize;
324
325            let column_height = (left_total_dots.max(right_total_dots) as f64 / dots_per_row as f64)
326                .ceil() as usize;
327
328            for (row_index, row) in column.rows().rev().enumerate().take(column_height) {
329                let value = f64::midpoint(left_value, right_value);
330                let color = self.color_for(area, min, max, value, row_index);
331
332                let dots_below = row_index * dots_per_row;
333                let left_dots = left_total_dots.saturating_sub(dots_below).min(dots_per_row);
334                let right_dots = right_total_dots
335                    .saturating_sub(dots_below)
336                    .min(dots_per_row);
337
338                let symbol = patterns[left_dots][right_dots];
339                buf[row].set_symbol(symbol).set_fg(color);
340            }
341        }
342    }
343
344    fn color_for(&self, area: Rect, min: f64, max: f64, value: f64, row: usize) -> Color {
345        let color_value = match self.color_mode {
346            ColorMode::Solid => value,
347            ColorMode::VerticalGradient => {
348                (row as f64 / area.height as f64).mul_add(max - min, min)
349            }
350        };
351        self.gradient
352            .as_ref()
353            .map(|gradient| {
354                let color = gradient.at(color_value as f32);
355                let rgba = color.to_rgba8();
356                // TODO this can be changed to .into() in ratatui 0.30
357                Color::Rgb(rgba[0], rgba[1], rgba[2])
358            })
359            .unwrap_or(Color::Reset)
360    }
361}
362
363impl Widget for BarGraph<'_> {
364    fn render(self, area: Rect, buf: &mut Buffer) {
365        // f64 doesn't impl Ord because NaN != NaN, so we use fold instead of iter::max/min
366        let min = self
367            .min
368            .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
369        let max = self
370            .max
371            .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
372        let max = max.max(min + f64::EPSILON); // avoid division by zero if min == max
373        match self.bar_style {
374            BarStyle::Braille => self.render_braille(area, buf, min, max),
375            BarStyle::Solid => self.render_solid(area, buf, min, max),
376            BarStyle::Quadrant => self.render_quadrant(area, buf, min, max),
377            BarStyle::Octant => self.render_octant(area, buf, min, max),
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn with_gradient() {
388        let data = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
389        // check that we can use either a gradient or a boxed gradient
390        let _graph = BarGraph::new(data.clone()).with_gradient(colorgrad::preset::turbo());
391        let _graph = BarGraph::new(data).with_gradient(colorgrad::preset::turbo().boxed());
392    }
393
394    #[test]
395    fn braille() {
396        let data = (0..=40).map(|i| i as f64 * 0.125).collect();
397        let bar_graph = BarGraph::new(data);
398
399        let mut buf = Buffer::empty(Rect::new(0, 0, 21, 10));
400        bar_graph.render(buf.area, &mut buf);
401
402        assert_eq!(
403            buf,
404            Buffer::with_lines(vec![
405                "                  ⢀⣴⡇",
406                "                ⢀⣴⣿⣿⡇",
407                "              ⢀⣴⣿⣿⣿⣿⡇",
408                "            ⢀⣴⣿⣿⣿⣿⣿⣿⡇",
409                "          ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⡇",
410                "        ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
411                "      ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
412                "    ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
413                "  ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
414                "⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
415            ])
416        );
417    }
418
419    #[test]
420    fn solid() {
421        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];
422        let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Solid);
423
424        let mut buf = Buffer::empty(Rect::new(0, 0, 11, 10));
425        bar_graph.render(buf.area, &mut buf);
426
427        assert_eq!(
428            buf,
429            Buffer::with_lines(vec![
430                "          █",
431                "         ██",
432                "        ███",
433                "       ████",
434                "      █████",
435                "     ██████",
436                "    ███████",
437                "   ████████",
438                "  █████████",
439                " ██████████",
440            ])
441        );
442    }
443
444    #[test]
445    fn quadrant() {
446        let data = vec![
447            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,
448            4.0, 4.25, 4.5, 4.75, 5.0,
449        ];
450        let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Quadrant);
451
452        let mut buf = Buffer::empty(Rect::new(0, 0, 11, 10));
453        bar_graph.render(buf.area, &mut buf);
454
455        assert_eq!(
456            buf,
457            Buffer::with_lines(vec![
458                "         ▗▌",
459                "        ▗█▌",
460                "       ▗██▌",
461                "      ▗███▌",
462                "     ▗████▌",
463                "    ▗█████▌",
464                "   ▗██████▌",
465                "  ▗███████▌",
466                " ▗████████▌",
467                "▗█████████▌",
468            ])
469        );
470    }
471
472    #[test]
473    fn octant() {
474        let data = (0..=40).map(|i| i as f64 * 0.125).collect();
475        let bar_graph = BarGraph::new(data).with_bar_style(BarStyle::Octant);
476
477        let mut buf = Buffer::empty(Rect::new(0, 0, 21, 10));
478        bar_graph.render(buf.area, &mut buf);
479
480        assert_eq!(
481            buf,
482            Buffer::with_lines(vec![
483                "                  𜺠𜷡▌",
484                "                𜺠𜷡██▌",
485                "              𜺠𜷡████▌",
486                "            𜺠𜷡██████▌",
487                "          𜺠𜷡████████▌",
488                "        𜺠𜷡██████████▌",
489                "      𜺠𜷡████████████▌",
490                "    𜺠𜷡██████████████▌",
491                "  𜺠𜷡████████████████▌",
492                "𜺠𜷡██████████████████▌",
493            ])
494        );
495    }
496}