1use ratatui_core::{
2 buffer::Buffer,
3 layout::{Constraint, Rect},
4 style::{Color, Style},
5 widgets::Widget,
6};
7
8use crate::animation::{cell_intensity, interpolate_color, AnimationMode};
9use crate::defaults;
10
11#[must_use]
16#[derive(Debug, Clone)]
17pub struct SkeletonTable<'a> {
18 elapsed_ms: u64,
19 mode: AnimationMode,
20 base: Color,
21 highlight: Color,
22 rows: u16,
23 columns: &'a [Constraint],
24 zebra: bool,
25 block: Option<ratatui_widgets::block::Block<'a>>,
26}
27
28const ZEBRA_OFFSET: f32 = 0.15;
30
31impl<'a> SkeletonTable<'a> {
32 pub fn new(elapsed_ms: u64) -> Self {
33 Self {
34 elapsed_ms,
35 mode: AnimationMode::default(),
36 base: defaults::BASE,
37 highlight: defaults::HIGHLIGHT,
38 rows: 5,
39 columns: &[],
40 zebra: true,
41 block: None,
42 }
43 }
44
45 pub fn mode(mut self, mode: AnimationMode) -> Self {
46 self.mode = mode;
47 self
48 }
49
50 pub fn base(mut self, color: impl Into<Color>) -> Self {
51 self.base = color.into();
52 self
53 }
54
55 pub fn highlight(mut self, color: impl Into<Color>) -> Self {
56 self.highlight = color.into();
57 self
58 }
59
60 pub fn rows(mut self, rows: u16) -> Self {
61 self.rows = rows;
62 self
63 }
64
65 pub fn columns(mut self, columns: &'a [Constraint]) -> Self {
66 self.columns = columns;
67 self
68 }
69
70 pub fn zebra(mut self, zebra: bool) -> Self {
72 self.zebra = zebra;
73 self
74 }
75
76 pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
77 self.block = Some(block);
78 self
79 }
80}
81
82impl Widget for SkeletonTable<'_> {
83 fn render(self, area: Rect, buf: &mut Buffer) {
84 let inner = if let Some(ref block) = self.block {
85 let inner_area = block.inner(area);
86 block.render(area, buf);
87 inner_area
88 } else {
89 area
90 };
91
92 if inner.is_empty() {
93 return;
94 }
95
96 let col_offsets = resolve_columns(self.columns, inner.width);
97
98 let breathe_t = matches!(self.mode, AnimationMode::Breathe)
100 .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
101
102 let row_count = self.rows.min(inner.height);
103
104 for row in 0..row_count {
105 let y = inner.y + row;
106
107 let zebra_boost = if self.zebra && row % 2 == 1 {
108 ZEBRA_OFFSET
109 } else {
110 0.0
111 };
112
113 for col in 0..inner.width {
114 let x = inner.x + col;
115
116 if is_separator(&col_offsets, col) {
118 buf[(x, y)]
119 .set_char('│')
120 .set_style(Style::default().fg(self.base));
121 continue;
122 }
123
124 let t = breathe_t.unwrap_or_else(|| {
125 cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
126 });
127 let t = (t + zebra_boost).min(1.0);
128 let fg = interpolate_color(self.base, self.highlight, self.mode, t);
129
130 buf[(x, y)].set_char('█').set_style(Style::default().fg(fg));
131 }
132 }
133 }
134}
135
136fn resolve_columns(constraints: &[Constraint], width: u16) -> Vec<u16> {
140 if constraints.is_empty() {
141 return Vec::new();
142 }
143
144 let total_seps = constraints.len().saturating_sub(1) as u16;
145 let available = width.saturating_sub(total_seps);
146
147 let widths: Vec<u16> = constraints
148 .iter()
149 .map(|c| match c {
150 Constraint::Length(n) | Constraint::Min(n) | Constraint::Max(n) => (*n).min(available),
151 Constraint::Percentage(p) => (available as u32 * (*p).min(100) as u32 / 100) as u16,
152 Constraint::Ratio(num, den) => {
153 if *den == 0 {
154 0
155 } else {
156 (available as u32 * *num / *den) as u16
157 }
158 }
159 Constraint::Fill(_) => 0,
160 })
161 .collect();
162
163 let allocated: u16 = widths.iter().sum();
165 let remaining = available.saturating_sub(allocated);
166 let fill_count = constraints
167 .iter()
168 .filter(|c| matches!(c, Constraint::Fill(_)))
169 .count() as u16;
170
171 let widths: Vec<u16> = if fill_count > 0 {
172 let fill_each = remaining / fill_count.max(1);
173
174 widths
175 .iter()
176 .zip(constraints)
177 .map(|(w, c)| {
178 if matches!(c, Constraint::Fill(_)) {
179 fill_each
180 } else {
181 *w
182 }
183 })
184 .collect()
185 } else {
186 widths
187 };
188
189 let mut offsets = Vec::with_capacity(constraints.len().saturating_sub(1));
191 let mut x = 0u16;
192
193 for (i, w) in widths.iter().enumerate() {
194 x += w;
195
196 if i < widths.len() - 1 {
197 offsets.push(x);
198 x += 1; }
200 }
201
202 offsets
203}
204
205fn is_separator(offsets: &[u16], col: u16) -> bool {
206 offsets.contains(&col)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn renders_correct_row_count() {
215 let area = Rect::new(0, 0, 20, 10);
216 let mut buf = Buffer::empty(area);
217
218 SkeletonTable::new(1000).rows(3).render(area, &mut buf);
219
220 assert_ne!(buf[(0, 0)].symbol(), " ");
222 assert_ne!(buf[(0, 2)].symbol(), " ");
223 assert_eq!(buf[(0, 3)].symbol(), " ");
224 }
225
226 #[test]
227 fn column_separators_present() {
228 let cols = [Constraint::Length(5), Constraint::Length(5)];
229 let area = Rect::new(0, 0, 11, 3);
230 let mut buf = Buffer::empty(area);
231
232 SkeletonTable::new(1000)
233 .columns(&cols)
234 .rows(3)
235 .render(area, &mut buf);
236
237 assert_eq!(buf[(5, 0)].symbol(), "│");
239 }
240
241 #[test]
242 fn empty_area_is_noop() {
243 let area = Rect::new(0, 0, 0, 0);
244 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
245 let expected = buf.clone();
246
247 SkeletonTable::new(0).render(area, &mut buf);
248
249 assert_eq!(buf, expected);
250 }
251
252 #[test]
253 fn resolve_percentage_columns() {
254 let constraints = [Constraint::Percentage(50), Constraint::Percentage(50)];
255 let offsets = resolve_columns(&constraints, 21);
256
257 assert_eq!(offsets, vec![10]);
259 }
260}