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(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn items_have_gaps() {
122        let area = Rect::new(0, 0, 20, 6);
123        let mut buf = Buffer::empty(area);
124
125        SkeletonList::new(1000)
126            .items(3)
127            .widths(&[0.5, 0.5, 0.5])
128            .render(area, &mut buf);
129
130        // Row 0: item content.
131        assert_eq!(buf[(0, 0)].symbol(), "█");
132
133        // Row 1: gap.
134        assert_eq!(buf[(0, 1)].symbol(), " ");
135
136        // Row 2: item content.
137        assert_eq!(buf[(0, 2)].symbol(), "█");
138
139        // Row 3: gap.
140        assert_eq!(buf[(0, 3)].symbol(), " ");
141    }
142
143    #[test]
144    fn ragged_edges() {
145        let area = Rect::new(0, 0, 20, 6);
146        let mut buf = Buffer::empty(area);
147
148        SkeletonList::new(1000)
149            .items(3)
150            .widths(&[0.5, 0.3, 0.8])
151            .render(area, &mut buf);
152
153        // Item 0 at 50% → col 9 filled, col 10 empty.
154        assert_eq!(buf[(9, 0)].symbol(), "█");
155        assert_eq!(buf[(10, 0)].symbol(), " ");
156
157        // Item 1 at 30% → col 5 filled, col 6 empty.
158        assert_eq!(buf[(5, 2)].symbol(), "█");
159        assert_eq!(buf[(6, 2)].symbol(), " ");
160    }
161
162    #[test]
163    fn respects_item_limit() {
164        let area = Rect::new(0, 0, 10, 10);
165        let mut buf = Buffer::empty(area);
166
167        SkeletonList::new(1000).items(2).render(area, &mut buf);
168
169        // 2 items = rows 0,2 filled; row 4 should be empty.
170        assert_ne!(buf[(0, 0)].symbol(), " ");
171        assert_ne!(buf[(0, 2)].symbol(), " ");
172        assert_eq!(buf[(0, 4)].symbol(), " ");
173    }
174}