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
7const DEFAULT_WIDTHS: [f32; 5] = [0.45, 0.30, 0.55, 0.35, 0.50];
9
10#[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 pub fn items(mut self, items: u16) -> Self {
56 self.items = items;
57 self
58 }
59
60 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 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 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 assert_eq!(buf[(0, 0)].symbol(), "█");
132
133 assert_eq!(buf[(0, 1)].symbol(), " ");
135
136 assert_eq!(buf[(0, 2)].symbol(), "█");
138
139 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 assert_eq!(buf[(9, 0)].symbol(), "█");
155 assert_eq!(buf[(10, 0)].symbol(), " ");
156
157 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 assert_ne!(buf[(0, 0)].symbol(), " ");
171 assert_ne!(buf[(0, 2)].symbol(), " ");
172 assert_eq!(buf[(0, 4)].symbol(), " ");
173 }
174}