Skip to main content

tui_skeleton/
hbar_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 width fractions cycling across bars.
12const DEFAULT_WIDTHS: [f32; 7] = [0.85, 0.60, 0.95, 0.45, 0.75, 0.55, 0.70];
13
14/// Skeleton horizontal bar chart with bars of varying length.
15///
16/// Renders rows of `█` extending from the left edge, separated by
17/// single-row gaps. Widths cycle deterministically. Mimics a
18/// leaderboard or ranking display.
19#[must_use]
20#[derive(Debug, Clone)]
21pub struct SkeletonHBarChart<'a> {
22    elapsed_ms: u64,
23    mode: AnimationMode,
24    base: Color,
25    highlight: Color,
26    bars: u16,
27    bar_height: u16,
28    widths: &'a [f32],
29    block: Option<ratatui_widgets::block::Block<'a>>,
30}
31
32impl<'a> SkeletonHBarChart<'a> {
33    pub fn new(elapsed_ms: u64) -> Self {
34        Self {
35            elapsed_ms,
36            mode: AnimationMode::default(),
37            base: defaults::BASE,
38            highlight: defaults::HIGHLIGHT,
39            bars: 5,
40            bar_height: 1,
41            widths: &DEFAULT_WIDTHS,
42            block: None,
43        }
44    }
45
46    pub fn mode(mut self, mode: AnimationMode) -> Self {
47        self.mode = mode;
48        self
49    }
50
51    pub fn base(mut self, color: impl Into<Color>) -> Self {
52        self.base = color.into();
53        self
54    }
55
56    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
57        self.highlight = color.into();
58        self
59    }
60
61    /// Number of horizontal bars. Default: `5`.
62    pub fn bars(mut self, bars: u16) -> Self {
63        self.bars = bars;
64        self
65    }
66
67    /// Height of each bar in rows. Default: `1`.
68    pub fn bar_height(mut self, height: u16) -> Self {
69        self.bar_height = height;
70        self
71    }
72
73    /// Override the per-bar width fractions (`0.0..=1.0`).
74    ///
75    /// The pattern cycles when there are more bars than entries.
76    pub fn widths(mut self, widths: &'a [f32]) -> Self {
77        self.widths = widths;
78        self
79    }
80
81    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
82        self.block = Some(block);
83        self
84    }
85}
86
87impl Widget for SkeletonHBarChart<'_> {
88    fn render(self, area: Rect, buf: &mut Buffer) {
89        let inner = if let Some(ref block) = self.block {
90            let inner_area = block.inner(area);
91            block.render(area, buf);
92            inner_area
93        } else {
94            area
95        };
96
97        if inner.is_empty() || self.widths.is_empty() || self.bar_height == 0 {
98            return;
99        }
100
101        let stride = self.bar_height + 1; // bar + 1-row gap
102        let bar_count = self.bars.min((inner.height + 1) / stride);
103
104        // Breathe is uniform — hoist.
105        let breathe_t = matches!(self.mode, AnimationMode::Breathe)
106            .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
107
108        for i in 0..bar_count {
109            let frac = self.widths[i as usize % self.widths.len()].clamp(0.0, 1.0);
110            let bar_width = ((inner.width as f32) * frac).ceil() as u16;
111            let bar_y = inner.y + i * stride;
112
113            for dy in 0..self.bar_height {
114                let y = bar_y + dy;
115
116                if y >= inner.bottom() {
117                    break;
118                }
119
120                for col in 0..bar_width.min(inner.width) {
121                    let x = inner.x + col;
122
123                    let t = breathe_t.unwrap_or_else(|| {
124                        cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
125                    });
126                    let fg = interpolate_color(self.base, self.highlight, self.mode, t);
127
128                    buf[(x, y)].set_char('█').set_style(Style::default().fg(fg));
129                }
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn bars_extend_from_left() {
141        let area = Rect::new(0, 0, 20, 5);
142        let mut buf = Buffer::empty(area);
143
144        SkeletonHBarChart::new(1000)
145            .bars(1)
146            .bar_height(1)
147            .widths(&[0.5])
148            .render(area, &mut buf);
149
150        // 50% of 20 = 10 cells.
151        assert_eq!(buf[(9, 0)].symbol(), "█");
152        assert_eq!(buf[(10, 0)].symbol(), " ");
153    }
154
155    #[test]
156    fn bars_have_gaps() {
157        let area = Rect::new(0, 0, 10, 5);
158        let mut buf = Buffer::empty(area);
159
160        SkeletonHBarChart::new(1000)
161            .bars(2)
162            .bar_height(1)
163            .widths(&[1.0, 1.0])
164            .render(area, &mut buf);
165
166        // Bar 0: row 0. Gap: row 1. Bar 1: row 2.
167        assert_eq!(buf[(0, 0)].symbol(), "█");
168        assert_eq!(buf[(0, 1)].symbol(), " ");
169        assert_eq!(buf[(0, 2)].symbol(), "█");
170    }
171
172    #[test]
173    fn multi_row_bars() {
174        let area = Rect::new(0, 0, 10, 7);
175        let mut buf = Buffer::empty(area);
176
177        SkeletonHBarChart::new(1000)
178            .bars(2)
179            .bar_height(2)
180            .widths(&[1.0, 1.0])
181            .render(area, &mut buf);
182
183        // Bar 0: rows 0-1. Gap: row 2. Bar 1: rows 3-4.
184        assert_eq!(buf[(0, 0)].symbol(), "█");
185        assert_eq!(buf[(0, 1)].symbol(), "█");
186        assert_eq!(buf[(0, 2)].symbol(), " ");
187        assert_eq!(buf[(0, 3)].symbol(), "█");
188        assert_eq!(buf[(0, 4)].symbol(), "█");
189    }
190}