Skip to main content

tui_skeleton/
bar_chart.rs

1use ratatui_core::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Style},
5    widgets::Widget,
6};
7
8use crate::animation::{AnimationMode, cell_intensity, interpolate_color, is_uniform};
9use crate::defaults;
10
11/// Deterministic height fractions cycling across bars.
12const DEFAULT_HEIGHTS: [f32; 7] = [0.6, 0.85, 0.45, 0.95, 0.70, 0.55, 0.80];
13
14/// Skeleton vertical bar chart with bars of varying height.
15///
16/// Renders columns of `█` rising from the bottom of the area,
17/// separated by single-cell gaps. Heights cycle deterministically.
18#[must_use]
19#[derive(Debug, Clone)]
20pub struct SkeletonBarChart<'a> {
21    elapsed_ms: u64,
22    mode: AnimationMode,
23    braille: bool,
24    base: Color,
25    highlight: Color,
26    bars: u16,
27    bar_width: u16,
28    heights: &'a [f32],
29    block: Option<ratatui_widgets::block::Block<'a>>,
30}
31
32impl<'a> SkeletonBarChart<'a> {
33    pub fn new(elapsed_ms: u64) -> Self {
34        Self {
35            elapsed_ms,
36            mode: AnimationMode::default(),
37            braille: false,
38            base: defaults::BASE,
39            highlight: defaults::HIGHLIGHT,
40            bars: 6,
41            bar_width: 3,
42            heights: &DEFAULT_HEIGHTS,
43            block: None,
44        }
45    }
46
47    pub fn mode(mut self, mode: AnimationMode) -> Self {
48        self.mode = mode;
49        self
50    }
51
52    pub fn braille(mut self, braille: bool) -> Self {
53        self.braille = braille;
54        self
55    }
56
57    pub fn base(mut self, color: impl Into<Color>) -> Self {
58        self.base = color.into();
59        self
60    }
61
62    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
63        self.highlight = color.into();
64        self
65    }
66
67    /// Number of bars to render. Default: `6`.
68    pub fn bars(mut self, bars: u16) -> Self {
69        self.bars = bars;
70        self
71    }
72
73    /// Width of each bar in cells. Default: `3`.
74    pub fn bar_width(mut self, width: u16) -> Self {
75        self.bar_width = width;
76        self
77    }
78
79    /// Override the per-bar height fractions (`0.0..=1.0`).
80    ///
81    /// The pattern cycles when there are more bars than entries.
82    pub fn heights(mut self, heights: &'a [f32]) -> Self {
83        self.heights = heights;
84        self
85    }
86
87    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
88        self.block = Some(block);
89        self
90    }
91}
92
93impl Widget for SkeletonBarChart<'_> {
94    fn render(self, area: Rect, buf: &mut Buffer) {
95        let inner = if let Some(ref block) = self.block {
96            let inner_area = block.inner(area);
97            block.render(area, buf);
98            inner_area
99        } else {
100            area
101        };
102
103        if inner.is_empty() || self.heights.is_empty() || self.bar_width == 0 {
104            return;
105        }
106
107        let stride = self.bar_width + 1; // bar + 1-cell gap
108        let bar_count = self.bars.min((inner.width + 1) / stride);
109
110        let uniform_t = is_uniform(self.mode)
111            .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
112
113        for i in 0..bar_count {
114            let frac = self.heights[i as usize % self.heights.len()].clamp(0.0, 1.0);
115            let bar_height = ((inner.height as f32) * frac).ceil() as u16;
116            let bar_x = inner.x + i * stride;
117            let bar_top = inner.y + inner.height - bar_height;
118
119            for dy in 0..bar_height {
120                let y = bar_top + dy;
121                let row = y - inner.y;
122
123                for dx in 0..self.bar_width {
124                    let x = bar_x + dx;
125
126                    if x >= inner.right() {
127                        break;
128                    }
129
130                    let col = x - inner.x;
131                    let t = uniform_t.unwrap_or_else(|| {
132                        cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
133                    });
134                    let fg = interpolate_color(self.base, self.highlight, self.mode, t);
135                    let glyph = crate::animation::cell_glyph(
136                        self.braille,
137                        self.mode,
138                        self.elapsed_ms,
139                        row,
140                        col,
141                    );
142
143                    buf[(x, y)]
144                        .set_char(glyph)
145                        .set_style(Style::default().fg(fg));
146                }
147            }
148        }
149    }
150}
151
152#[cfg(feature = "pantry")]
153#[path = "bar_chart.ingredient.rs"]
154pub mod ingredient;
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn bars_rise_from_bottom() {
162        let area = Rect::new(0, 0, 10, 10);
163        let mut buf = Buffer::empty(area);
164
165        SkeletonBarChart::new(1000)
166            .bars(1)
167            .bar_width(2)
168            .heights(&[0.5])
169            .render(area, &mut buf);
170
171        // 50% of 10 = 5 rows. Top cell of bar at row 5.
172        assert_eq!(buf[(0, 5)].symbol(), "█");
173        assert_eq!(buf[(1, 5)].symbol(), "█");
174
175        // Row 4 should be empty (above the bar).
176        assert_eq!(buf[(0, 4)].symbol(), " ");
177
178        // Bottom row should be filled.
179        assert_eq!(buf[(0, 9)].symbol(), "█");
180    }
181
182    #[test]
183    fn bars_have_gaps() {
184        let area = Rect::new(0, 0, 10, 5);
185        let mut buf = Buffer::empty(area);
186
187        SkeletonBarChart::new(1000)
188            .bars(2)
189            .bar_width(2)
190            .heights(&[1.0, 1.0])
191            .render(area, &mut buf);
192
193        // Bar 0: cols 0-1. Bar 1: cols 3-4. Col 2 is the gap.
194        assert_eq!(buf[(0, 0)].symbol(), "█");
195        assert_eq!(buf[(1, 0)].symbol(), "█");
196        assert_eq!(buf[(2, 0)].symbol(), " ");
197        assert_eq!(buf[(3, 0)].symbol(), "█");
198    }
199
200    #[test]
201    fn overflow_bars_clipped() {
202        let area = Rect::new(0, 0, 5, 5);
203        let mut buf = Buffer::empty(area);
204
205        // bar_width=3, stride=4. Only 1 bar fits in width 5.
206        SkeletonBarChart::new(1000)
207            .bars(3)
208            .bar_width(3)
209            .heights(&[1.0])
210            .render(area, &mut buf);
211
212        assert_eq!(buf[(0, 0)].symbol(), "█");
213        assert_eq!(buf[(2, 0)].symbol(), "█");
214
215        // Second bar would start at col 4, only 1 cell fits.
216        // bar_count is min(3, (5+1)/4=1) = 1, so second bar not rendered.
217    }
218}