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(feature = "pantry")]
118#[path = "block.ingredient.rs"]
119pub mod ingredient;
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn render_block(elapsed_ms: u64, width: u16, height: u16) -> Buffer {
126        let area = Rect::new(0, 0, width, height);
127        let mut buf = Buffer::empty(area);
128
129        SkeletonBlock::new(elapsed_ms).render(area, &mut buf);
130
131        buf
132    }
133
134    #[test]
135    fn fills_all_cells() {
136        let buf = render_block(1000, 10, 3);
137
138        for y in 0..3 {
139            for x in 0..10 {
140                assert_eq!(buf[(x, y)].symbol(), "█");
141            }
142        }
143    }
144
145    #[test]
146    fn empty_area_is_noop() {
147        let area = Rect::new(0, 0, 0, 0);
148        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
149        let expected = buf.clone();
150
151        SkeletonBlock::new(0).render(area, &mut buf);
152
153        assert_eq!(buf, expected);
154    }
155
156    #[test]
157    fn custom_colors_applied() {
158        let area = Rect::new(0, 0, 5, 1);
159        let mut buf = Buffer::empty(area);
160
161        SkeletonBlock::new(0)
162            .base(Color::Rgb(10, 20, 30))
163            .highlight(Color::Rgb(200, 200, 200))
164            .render(area, &mut buf);
165
166        // At elapsed_ms=0, Breathe intensity is 0.0 → all cells should be base color.
167        for x in 0..5 {
168            let style = buf[(x, 0u16)].style();
169            assert_eq!(style.fg, Some(Color::Rgb(10, 20, 30)));
170        }
171    }
172}