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::{cell_intensity, interpolate_color, AnimationMode};
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    base: Color,
24    highlight: Color,
25    bars: u16,
26    bar_width: u16,
27    heights: &'a [f32],
28    block: Option<ratatui_widgets::block::Block<'a>>,
29}
30
31impl<'a> SkeletonBarChart<'a> {
32    pub fn new(elapsed_ms: u64) -> Self {
33        Self {
34            elapsed_ms,
35            mode: AnimationMode::default(),
36            base: defaults::BASE,
37            highlight: defaults::HIGHLIGHT,
38            bars: 6,
39            bar_width: 3,
40            heights: &DEFAULT_HEIGHTS,
41            block: None,
42        }
43    }
44
45    pub fn mode(mut self, mode: AnimationMode) -> Self {
46        self.mode = mode;
47        self
48    }
49
50    pub fn base(mut self, color: impl Into<Color>) -> Self {
51        self.base = color.into();
52        self
53    }
54
55    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
56        self.highlight = color.into();
57        self
58    }
59
60    /// Number of bars to render. Default: `6`.
61    pub fn bars(mut self, bars: u16) -> Self {
62        self.bars = bars;
63        self
64    }
65
66    /// Width of each bar in cells. Default: `3`.
67    pub fn bar_width(mut self, width: u16) -> Self {
68        self.bar_width = width;
69        self
70    }
71
72    /// Override the per-bar height fractions (`0.0..=1.0`).
73    ///
74    /// The pattern cycles when there are more bars than entries.
75    pub fn heights(mut self, heights: &'a [f32]) -> Self {
76        self.heights = heights;
77        self
78    }
79
80    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
81        self.block = Some(block);
82        self
83    }
84}
85
86impl Widget for SkeletonBarChart<'_> {
87    fn render(self, area: Rect, buf: &mut Buffer) {
88        let inner = if let Some(ref block) = self.block {
89            let inner_area = block.inner(area);
90            block.render(area, buf);
91            inner_area
92        } else {
93            area
94        };
95
96        if inner.is_empty() || self.heights.is_empty() || self.bar_width == 0 {
97            return;
98        }
99
100        let stride = self.bar_width + 1; // bar + 1-cell gap
101        let bar_count = self.bars.min((inner.width + 1) / stride);
102
103        // Breathe is uniform — hoist.
104        let breathe_t = matches!(self.mode, AnimationMode::Breathe)
105            .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
106
107        for i in 0..bar_count {
108            let frac = self.heights[i as usize % self.heights.len()].clamp(0.0, 1.0);
109            let bar_height = ((inner.height as f32) * frac).ceil() as u16;
110            let bar_x = inner.x + i * stride;
111            let bar_top = inner.y + inner.height - bar_height;
112
113            for dy in 0..bar_height {
114                let y = bar_top + dy;
115
116                for dx in 0..self.bar_width {
117                    let x = bar_x + dx;
118
119                    if x >= inner.right() {
120                        break;
121                    }
122
123                    let col = x - inner.x;
124                    let t = breathe_t.unwrap_or_else(|| {
125                        cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
126                    });
127                    let fg = interpolate_color(self.base, self.highlight, self.mode, t);
128
129                    buf[(x, y)].set_char('█').set_style(Style::default().fg(fg));
130                }
131            }
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn bars_rise_from_bottom() {
142        let area = Rect::new(0, 0, 10, 10);
143        let mut buf = Buffer::empty(area);
144
145        SkeletonBarChart::new(1000)
146            .bars(1)
147            .bar_width(2)
148            .heights(&[0.5])
149            .render(area, &mut buf);
150
151        // 50% of 10 = 5 rows. Top cell of bar at row 5.
152        assert_eq!(buf[(0, 5)].symbol(), "█");
153        assert_eq!(buf[(1, 5)].symbol(), "█");
154
155        // Row 4 should be empty (above the bar).
156        assert_eq!(buf[(0, 4)].symbol(), " ");
157
158        // Bottom row should be filled.
159        assert_eq!(buf[(0, 9)].symbol(), "█");
160    }
161
162    #[test]
163    fn bars_have_gaps() {
164        let area = Rect::new(0, 0, 10, 5);
165        let mut buf = Buffer::empty(area);
166
167        SkeletonBarChart::new(1000)
168            .bars(2)
169            .bar_width(2)
170            .heights(&[1.0, 1.0])
171            .render(area, &mut buf);
172
173        // Bar 0: cols 0-1. Bar 1: cols 3-4. Col 2 is the gap.
174        assert_eq!(buf[(0, 0)].symbol(), "█");
175        assert_eq!(buf[(1, 0)].symbol(), "█");
176        assert_eq!(buf[(2, 0)].symbol(), " ");
177        assert_eq!(buf[(3, 0)].symbol(), "█");
178    }
179
180    #[test]
181    fn overflow_bars_clipped() {
182        let area = Rect::new(0, 0, 5, 5);
183        let mut buf = Buffer::empty(area);
184
185        // bar_width=3, stride=4. Only 1 bar fits in width 5.
186        SkeletonBarChart::new(1000)
187            .bars(3)
188            .bar_width(3)
189            .heights(&[1.0])
190            .render(area, &mut buf);
191
192        assert_eq!(buf[(0, 0)].symbol(), "█");
193        assert_eq!(buf[(2, 0)].symbol(), "█");
194
195        // Second bar would start at col 4, only 1 cell fits.
196        // bar_count is min(3, (5+1)/4=1) = 1, so second bar not rendered.
197    }
198}