Skip to main content

tui_skeleton/
list.rs

1use ratatui_core::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
2
3use crate::animation::AnimationMode;
4use crate::block::render_skeleton_cells;
5use crate::defaults;
6
7/// Deterministic width fractions cycling across items to simulate list variance.
8const DEFAULT_WIDTHS: [f32; 5] = [0.45, 0.30, 0.55, 0.35, 0.50];
9
10/// Skeleton list with short, spaced items and ragged right edges.
11///
12/// Each item is a single row separated by a blank gap row, mimicking
13/// a menu or sidebar. Width fractions cycle deterministically.
14#[must_use]
15#[derive(Debug, Clone)]
16pub struct SkeletonList<'a> {
17    elapsed_ms: u64,
18    mode: AnimationMode,
19    base: Color,
20    highlight: Color,
21    items: u16,
22    widths: &'a [f32],
23    block: Option<ratatui_widgets::block::Block<'a>>,
24}
25
26impl<'a> SkeletonList<'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            items: 5,
34            widths: &DEFAULT_WIDTHS,
35            block: None,
36        }
37    }
38
39    pub fn mode(mut self, mode: AnimationMode) -> Self {
40        self.mode = mode;
41        self
42    }
43
44    pub fn base(mut self, color: impl Into<Color>) -> Self {
45        self.base = color.into();
46        self
47    }
48
49    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
50        self.highlight = color.into();
51        self
52    }
53
54    /// Number of list items to display. Default: `5`.
55    pub fn items(mut self, items: u16) -> Self {
56        self.items = items;
57        self
58    }
59
60    /// Override the per-item width fractions (`0.0..=1.0`).
61    ///
62    /// The pattern cycles when there are more items than entries.
63    pub fn widths(mut self, widths: &'a [f32]) -> Self {
64        self.widths = widths;
65        self
66    }
67
68    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
69        self.block = Some(block);
70        self
71    }
72}
73
74impl Widget for SkeletonList<'_> {
75    fn render(self, area: Rect, buf: &mut Buffer) {
76        let inner = if let Some(ref block) = self.block {
77            let inner_area = block.inner(area);
78            block.render(area, buf);
79            inner_area
80        } else {
81            area
82        };
83
84        if inner.is_empty() || self.widths.is_empty() {
85            return;
86        }
87
88        // Each item occupies 1 row of content + 1 row of gap.
89        let rows_needed = self.items * 2;
90        let render_height = rows_needed.min(inner.height);
91        let widths = self.widths;
92        let total_width = inner.width;
93
94        render_skeleton_cells(
95            Rect::new(inner.x, inner.y, inner.width, render_height),
96            buf,
97            self.mode,
98            self.elapsed_ms,
99            self.base,
100            self.highlight,
101            |row, col, _width| {
102                // Odd rows are gaps.
103                if row % 2 == 1 {
104                    return false;
105                }
106
107                let item_index = (row / 2) as usize;
108                let frac = widths[item_index % widths.len()].clamp(0.0, 1.0);
109                let item_width = (total_width as f32 * frac) as u16;
110                col < item_width
111            },
112        );
113    }
114}
115
116#[cfg(feature = "pantry")]
117#[path = "list.ingredient.rs"]
118pub mod ingredient;
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn items_have_gaps() {
126        let area = Rect::new(0, 0, 20, 6);
127        let mut buf = Buffer::empty(area);
128
129        SkeletonList::new(1000)
130            .items(3)
131            .widths(&[0.5, 0.5, 0.5])
132            .render(area, &mut buf);
133
134        // Row 0: item content.
135        assert_eq!(buf[(0, 0)].symbol(), "█");
136
137        // Row 1: gap.
138        assert_eq!(buf[(0, 1)].symbol(), " ");
139
140        // Row 2: item content.
141        assert_eq!(buf[(0, 2)].symbol(), "█");
142
143        // Row 3: gap.
144        assert_eq!(buf[(0, 3)].symbol(), " ");
145    }
146
147    #[test]
148    fn ragged_edges() {
149        let area = Rect::new(0, 0, 20, 6);
150        let mut buf = Buffer::empty(area);
151
152        SkeletonList::new(1000)
153            .items(3)
154            .widths(&[0.5, 0.3, 0.8])
155            .render(area, &mut buf);
156
157        // Item 0 at 50% → col 9 filled, col 10 empty.
158        assert_eq!(buf[(9, 0)].symbol(), "█");
159        assert_eq!(buf[(10, 0)].symbol(), " ");
160
161        // Item 1 at 30% → col 5 filled, col 6 empty.
162        assert_eq!(buf[(5, 2)].symbol(), "█");
163        assert_eq!(buf[(6, 2)].symbol(), " ");
164    }
165
166    #[test]
167    fn respects_item_limit() {
168        let area = Rect::new(0, 0, 10, 10);
169        let mut buf = Buffer::empty(area);
170
171        SkeletonList::new(1000).items(2).render(area, &mut buf);
172
173        // 2 items = rows 0,2 filled; row 4 should be empty.
174        assert_ne!(buf[(0, 0)].symbol(), " ");
175        assert_ne!(buf[(0, 2)].symbol(), " ");
176        assert_eq!(buf[(0, 4)].symbol(), " ");
177    }
178}