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    braille: bool,
20    base: Color,
21    highlight: Color,
22    items: u16,
23    widths: &'a [f32],
24    block: Option<ratatui_widgets::block::Block<'a>>,
25}
26
27impl<'a> SkeletonList<'a> {
28    pub fn new(elapsed_ms: u64) -> Self {
29        Self {
30            elapsed_ms,
31            mode: AnimationMode::default(),
32            braille: false,
33            base: defaults::BASE,
34            highlight: defaults::HIGHLIGHT,
35            items: 5,
36            widths: &DEFAULT_WIDTHS,
37            block: None,
38        }
39    }
40
41    pub fn mode(mut self, mode: AnimationMode) -> Self {
42        self.mode = mode;
43        self
44    }
45
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    /// Number of list items to display. Default: `5`.
62    pub fn items(mut self, items: u16) -> Self {
63        self.items = items;
64        self
65    }
66
67    /// Override the per-item width fractions (`0.0..=1.0`).
68    ///
69    /// The pattern cycles when there are more items than entries.
70    pub fn widths(mut self, widths: &'a [f32]) -> Self {
71        self.widths = widths;
72        self
73    }
74
75    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
76        self.block = Some(block);
77        self
78    }
79}
80
81impl Widget for SkeletonList<'_> {
82    fn render(self, area: Rect, buf: &mut Buffer) {
83        let inner = if let Some(ref block) = self.block {
84            let inner_area = block.inner(area);
85            block.render(area, buf);
86            inner_area
87        } else {
88            area
89        };
90
91        if inner.is_empty() || self.widths.is_empty() {
92            return;
93        }
94
95        // Each item occupies 1 row of content + 1 row of gap.
96        let rows_needed = self.items * 2;
97        let render_height = rows_needed.min(inner.height);
98        let widths = self.widths;
99        let total_width = inner.width;
100
101        render_skeleton_cells(
102            Rect::new(inner.x, inner.y, inner.width, render_height),
103            buf,
104            self.mode,
105            self.braille,
106            self.elapsed_ms,
107            self.base,
108            self.highlight,
109            |row, col, _width| {
110                // Odd rows are gaps.
111                if row % 2 == 1 {
112                    return false;
113                }
114
115                let item_index = (row / 2) as usize;
116                let frac = widths[item_index % widths.len()].clamp(0.0, 1.0);
117                let item_width = (total_width as f32 * frac) as u16;
118                col < item_width
119            },
120        );
121    }
122}
123
124#[cfg(feature = "pantry")]
125#[path = "list.ingredient.rs"]
126pub mod ingredient;
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn items_have_gaps() {
134        let area = Rect::new(0, 0, 20, 6);
135        let mut buf = Buffer::empty(area);
136
137        SkeletonList::new(1000)
138            .items(3)
139            .widths(&[0.5, 0.5, 0.5])
140            .render(area, &mut buf);
141
142        // Row 0: item content.
143        assert_eq!(buf[(0, 0)].symbol(), "█");
144
145        // Row 1: gap.
146        assert_eq!(buf[(0, 1)].symbol(), " ");
147
148        // Row 2: item content.
149        assert_eq!(buf[(0, 2)].symbol(), "█");
150
151        // Row 3: gap.
152        assert_eq!(buf[(0, 3)].symbol(), " ");
153    }
154
155    #[test]
156    fn ragged_edges() {
157        let area = Rect::new(0, 0, 20, 6);
158        let mut buf = Buffer::empty(area);
159
160        SkeletonList::new(1000)
161            .items(3)
162            .widths(&[0.5, 0.3, 0.8])
163            .render(area, &mut buf);
164
165        // Item 0 at 50% → col 9 filled, col 10 empty.
166        assert_eq!(buf[(9, 0)].symbol(), "█");
167        assert_eq!(buf[(10, 0)].symbol(), " ");
168
169        // Item 1 at 30% → col 5 filled, col 6 empty.
170        assert_eq!(buf[(5, 2)].symbol(), "█");
171        assert_eq!(buf[(6, 2)].symbol(), " ");
172    }
173
174    #[test]
175    fn respects_item_limit() {
176        let area = Rect::new(0, 0, 10, 10);
177        let mut buf = Buffer::empty(area);
178
179        SkeletonList::new(1000).items(2).render(area, &mut buf);
180
181        // 2 items = rows 0,2 filled; row 4 should be empty.
182        assert_ne!(buf[(0, 0)].symbol(), " ");
183        assert_ne!(buf[(0, 2)].symbol(), " ");
184        assert_eq!(buf[(0, 4)].symbol(), " ");
185    }
186}