Skip to main content

tui_skeleton/
block.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/// Solid filled rectangle with animated brightness.
12///
13/// The atomic skeleton unit — fills every cell with `█` at a color
14/// interpolated between `base` and `highlight` according to the
15/// chosen [`AnimationMode`].
16#[must_use]
17#[derive(Debug, Clone)]
18pub struct SkeletonBlock<'a> {
19    elapsed_ms: u64,
20    mode: AnimationMode,
21    base: Color,
22    highlight: Color,
23    block: Option<ratatui_widgets::block::Block<'a>>,
24}
25
26impl<'a> SkeletonBlock<'a> {
27    pub fn new(elapsed_ms: u64) -> Self {
28        Self {
29            elapsed_ms,
30            mode: AnimationMode::default(),
31            base: defaults::BASE,
32            highlight: defaults::HIGHLIGHT,
33            block: None,
34        }
35    }
36
37    pub fn mode(mut self, mode: AnimationMode) -> Self {
38        self.mode = mode;
39        self
40    }
41
42    pub fn base(mut self, color: impl Into<Color>) -> Self {
43        self.base = color.into();
44        self
45    }
46
47    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
48        self.highlight = color.into();
49        self
50    }
51
52    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
53        self.block = Some(block);
54        self
55    }
56}
57
58impl Widget for SkeletonBlock<'_> {
59    fn render(self, area: Rect, buf: &mut Buffer) {
60        let inner = if let Some(ref block) = self.block {
61            let inner_area = block.inner(area);
62            block.render(area, buf);
63            inner_area
64        } else {
65            area
66        };
67
68        if inner.is_empty() {
69            return;
70        }
71
72        render_skeleton_cells(
73            inner,
74            buf,
75            self.mode,
76            self.elapsed_ms,
77            self.base,
78            self.highlight,
79            |_row, col, width| col < width,
80        );
81    }
82}
83
84/// Fill cells in `area` where `visible(row, col, width)` returns true.
85///
86/// Shared by all skeleton widget shapes.
87pub(crate) fn render_skeleton_cells(
88    area: Rect,
89    buf: &mut Buffer,
90    mode: AnimationMode,
91    elapsed_ms: u64,
92    base: Color,
93    highlight: Color,
94    visible: impl Fn(u16, u16, u16) -> bool,
95) {
96    // Breathe is uniform — hoist outside the per-cell loop.
97    let breathe_t = matches!(mode, AnimationMode::Breathe)
98        .then(|| cell_intensity(mode, elapsed_ms, 0, area.width));
99
100    for row in 0..area.height {
101        for col in 0..area.width {
102            if !visible(row, col, area.width) {
103                continue;
104            }
105
106            let t = breathe_t.unwrap_or_else(|| cell_intensity(mode, elapsed_ms, col, area.width));
107
108            let fg = interpolate_color(base, highlight, mode, t);
109
110            let cell = &mut buf[(area.x + col, area.y + row)];
111            cell.set_char('█');
112            cell.set_style(Style::default().fg(fg));
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn render_block(elapsed_ms: u64, width: u16, height: u16) -> Buffer {
122        let area = Rect::new(0, 0, width, height);
123        let mut buf = Buffer::empty(area);
124
125        SkeletonBlock::new(elapsed_ms).render(area, &mut buf);
126
127        buf
128    }
129
130    #[test]
131    fn fills_all_cells() {
132        let buf = render_block(1000, 10, 3);
133
134        for y in 0..3 {
135            for x in 0..10 {
136                assert_eq!(buf[(x, y)].symbol(), "█");
137            }
138        }
139    }
140
141    #[test]
142    fn empty_area_is_noop() {
143        let area = Rect::new(0, 0, 0, 0);
144        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
145        let expected = buf.clone();
146
147        SkeletonBlock::new(0).render(area, &mut buf);
148
149        assert_eq!(buf, expected);
150    }
151
152    #[test]
153    fn custom_colors_applied() {
154        let area = Rect::new(0, 0, 5, 1);
155        let mut buf = Buffer::empty(area);
156
157        SkeletonBlock::new(0)
158            .base(Color::Rgb(10, 20, 30))
159            .highlight(Color::Rgb(200, 200, 200))
160            .render(area, &mut buf);
161
162        // At elapsed_ms=0, Breathe intensity is 0.0 → all cells should be base color.
163        for x in 0..5 {
164            let style = buf[(x, 0u16)].style();
165            assert_eq!(style.fg, Some(Color::Rgb(10, 20, 30)));
166        }
167    }
168}