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::{AnimationMode, cell_glyph, cell_intensity, interpolate_color, is_uniform};
9use crate::defaults;
10
11/// Solid filled rectangle with animated brightness.
12///
13/// The atomic skeleton unit — fills every cell at a color interpolated
14/// between `base` and `highlight` according to the chosen
15/// [`AnimationMode`]. [`braille`](SkeletonBlock::braille) switches fill
16/// from `█` to `⣿`; [`AnimationMode::Noise`] uses random braille glyphs.
17#[must_use]
18#[derive(Debug, Clone)]
19pub struct SkeletonBlock<'a> {
20    elapsed_ms: u64,
21    mode: AnimationMode,
22    braille: bool,
23    base: Color,
24    highlight: Color,
25    block: Option<ratatui_widgets::block::Block<'a>>,
26}
27
28impl<'a> SkeletonBlock<'a> {
29    pub fn new(elapsed_ms: u64) -> Self {
30        Self {
31            elapsed_ms,
32            mode: AnimationMode::default(),
33            braille: false,
34            base: defaults::BASE,
35            highlight: defaults::HIGHLIGHT,
36            block: None,
37        }
38    }
39
40    pub fn mode(mut self, mode: AnimationMode) -> Self {
41        self.mode = mode;
42        self
43    }
44
45    /// Use random braille dot patterns instead of solid `█` fill.
46    pub fn braille(mut self, braille: bool) -> Self {
47        self.braille = braille;
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    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
62        self.block = Some(block);
63        self
64    }
65}
66
67impl Widget for SkeletonBlock<'_> {
68    fn render(self, area: Rect, buf: &mut Buffer) {
69        let inner = if let Some(ref block) = self.block {
70            let inner_area = block.inner(area);
71            block.render(area, buf);
72            inner_area
73        } else {
74            area
75        };
76
77        if inner.is_empty() {
78            return;
79        }
80
81        render_skeleton_cells(
82            inner,
83            buf,
84            self.mode,
85            self.braille,
86            self.elapsed_ms,
87            self.base,
88            self.highlight,
89            |_row, col, width| col < width,
90        );
91    }
92}
93
94/// Fill cells in `area` where `visible(row, col, width)` returns true.
95///
96/// Shared by all skeleton widget shapes. `braille: true` renders `⣿`.
97/// [`AnimationMode::Noise`] renders random braille glyphs.
98#[expect(clippy::too_many_arguments)]
99pub(crate) fn render_skeleton_cells(
100    area: Rect,
101    buf: &mut Buffer,
102    mode: AnimationMode,
103    braille: bool,
104    elapsed_ms: u64,
105    base: Color,
106    highlight: Color,
107    visible: impl Fn(u16, u16, u16) -> bool,
108) {
109    let uniform_t = is_uniform(mode).then(|| cell_intensity(mode, elapsed_ms, 0, area.width));
110
111    for row in 0..area.height {
112        for col in 0..area.width {
113            if !visible(row, col, area.width) {
114                continue;
115            }
116
117            let t = uniform_t.unwrap_or_else(|| cell_intensity(mode, elapsed_ms, col, area.width));
118            let fg = interpolate_color(base, highlight, mode, t);
119            let ch = cell_glyph(braille, mode, elapsed_ms, row, col);
120
121            let cell = &mut buf[(area.x + col, area.y + row)];
122            cell.set_char(ch);
123            cell.set_style(Style::default().fg(fg));
124        }
125    }
126}
127
128#[cfg(feature = "pantry")]
129#[path = "block.ingredient.rs"]
130pub mod ingredient;
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn render_block(elapsed_ms: u64, width: u16, height: u16) -> Buffer {
137        let area = Rect::new(0, 0, width, height);
138        let mut buf = Buffer::empty(area);
139
140        SkeletonBlock::new(elapsed_ms).render(area, &mut buf);
141
142        buf
143    }
144
145    #[test]
146    fn fills_all_cells() {
147        let buf = render_block(1000, 10, 3);
148
149        for y in 0..3 {
150            for x in 0..10 {
151                assert_eq!(buf[(x, y)].symbol(), "█");
152            }
153        }
154    }
155
156    #[test]
157    fn noise_mode_fills_random_braille() {
158        let area = Rect::new(0, 0, 10, 3);
159        let mut buf = Buffer::empty(area);
160
161        SkeletonBlock::new(1000)
162            .mode(AnimationMode::Noise)
163            .render(area, &mut buf);
164
165        for y in 0..3u16 {
166            for x in 0..10u16 {
167                let ch = buf[(x, y)].symbol().chars().next().unwrap();
168                assert!((0x2800..=0x28FF).contains(&(ch as u32)));
169            }
170        }
171    }
172
173    #[test]
174    fn braille_flag_fills_solid_braille() {
175        let area = Rect::new(0, 0, 10, 3);
176        let mut buf = Buffer::empty(area);
177
178        SkeletonBlock::new(1000)
179            .braille(true)
180            .render(area, &mut buf);
181
182        for y in 0..3u16 {
183            for x in 0..10u16 {
184                assert_eq!(buf[(x, y)].symbol(), "⣿");
185            }
186        }
187    }
188
189    #[test]
190    fn empty_area_is_noop() {
191        let area = Rect::new(0, 0, 0, 0);
192        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
193        let expected = buf.clone();
194
195        SkeletonBlock::new(0).render(area, &mut buf);
196
197        assert_eq!(buf, expected);
198    }
199
200    #[test]
201    fn custom_colors_applied() {
202        let area = Rect::new(0, 0, 5, 1);
203        let mut buf = Buffer::empty(area);
204
205        SkeletonBlock::new(0)
206            .base(Color::Rgb(10, 20, 30))
207            .highlight(Color::Rgb(200, 200, 200))
208            .render(area, &mut buf);
209
210        // At elapsed_ms=0, Breathe intensity is 0.0 → all cells should be base color.
211        for x in 0..5 {
212            let style = buf[(x, 0u16)].style();
213            assert_eq!(style.fg, Some(Color::Rgb(10, 20, 30)));
214        }
215    }
216}