1use ratatui_core::{
2 buffer::Buffer,
3 layout::{Constraint, Rect},
4 style::{Color, Style},
5 widgets::Widget,
6};
7
8use crate::animation::{AnimationMode, cell_intensity, interpolate_color, is_uniform};
9use crate::defaults;
10
11const DEFAULT_CELL_WIDTHS: [f32; 11] = [
13 0.45, 0.70, 0.30, 0.85, 0.55, 0.40, 0.75, 0.60, 0.35, 0.50, 0.65,
14];
15
16#[must_use]
23#[derive(Debug, Clone)]
24pub struct SkeletonTable<'a> {
25 elapsed_ms: u64,
26 mode: AnimationMode,
27 braille: bool,
28 base: Color,
29 highlight: Color,
30 rows: u16,
31 columns: &'a [Constraint],
32 cell_widths: &'a [f32],
33 zebra: bool,
34 block: Option<ratatui_widgets::block::Block<'a>>,
35}
36
37const ZEBRA_OFFSET: f32 = 0.15;
39
40impl<'a> SkeletonTable<'a> {
41 pub fn new(elapsed_ms: u64) -> Self {
42 Self {
43 elapsed_ms,
44 mode: AnimationMode::default(),
45 braille: false,
46 base: defaults::BASE,
47 highlight: defaults::HIGHLIGHT,
48 rows: 5,
49 columns: &[],
50 cell_widths: &DEFAULT_CELL_WIDTHS,
51 zebra: true,
52 block: None,
53 }
54 }
55
56 pub fn mode(mut self, mode: AnimationMode) -> Self {
57 self.mode = mode;
58 self
59 }
60
61 pub fn braille(mut self, braille: bool) -> Self {
62 self.braille = braille;
63 self
64 }
65
66 pub fn base(mut self, color: impl Into<Color>) -> Self {
67 self.base = color.into();
68 self
69 }
70
71 pub fn highlight(mut self, color: impl Into<Color>) -> Self {
72 self.highlight = color.into();
73 self
74 }
75
76 pub fn rows(mut self, rows: u16) -> Self {
77 self.rows = rows;
78 self
79 }
80
81 pub fn columns(mut self, columns: &'a [Constraint]) -> Self {
82 self.columns = columns;
83 self
84 }
85
86 pub fn cell_widths(mut self, widths: &'a [f32]) -> Self {
92 self.cell_widths = widths;
93 self
94 }
95
96 pub fn zebra(mut self, zebra: bool) -> Self {
98 self.zebra = zebra;
99 self
100 }
101
102 pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
103 self.block = Some(block);
104 self
105 }
106}
107
108impl Widget for SkeletonTable<'_> {
109 fn render(self, area: Rect, buf: &mut Buffer) {
110 let inner = if let Some(ref block) = self.block {
111 let inner_area = block.inner(area);
112 block.render(area, buf);
113 inner_area
114 } else {
115 area
116 };
117
118 if inner.is_empty() {
119 return;
120 }
121
122 let col_offsets = resolve_columns(self.columns, inner.width);
123 let col_ranges = column_ranges(&col_offsets, inner.width);
124 let num_cols = col_ranges.len().max(1);
125
126 let uniform_t = is_uniform(self.mode)
127 .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
128
129 let row_count = self.rows.min(inner.height);
130
131 for row in 0..row_count {
132 let y = inner.y + row;
133
134 let zebra_boost = if self.zebra && row % 2 == 1 {
135 ZEBRA_OFFSET
136 } else {
137 0.0
138 };
139
140 for &sep in &col_offsets {
142 let x = inner.x + sep;
143
144 buf[(x, y)]
145 .set_char('│')
146 .set_style(Style::default().fg(self.base));
147 }
148
149 for (ci, &(start, width)) in col_ranges.iter().enumerate() {
151 let cell_idx = row as usize * num_cols + ci;
152 let frac = self.cell_widths[cell_idx % self.cell_widths.len()].clamp(0.0, 1.0);
153 let fill_width = ((width as f32) * frac).ceil() as u16;
154
155 for dx in 0..fill_width.min(width) {
156 let col = start + dx;
157 let x = inner.x + col;
158
159 let t = uniform_t.unwrap_or_else(|| {
160 cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
161 });
162 let t = (t + zebra_boost).min(1.0);
163 let fg = interpolate_color(self.base, self.highlight, self.mode, t);
164 let glyph = crate::animation::cell_glyph(
165 self.braille,
166 self.mode,
167 self.elapsed_ms,
168 row,
169 col,
170 );
171
172 buf[(x, y)]
173 .set_char(glyph)
174 .set_style(Style::default().fg(fg));
175 }
176 }
177 }
178 }
179}
180
181fn resolve_columns(constraints: &[Constraint], width: u16) -> Vec<u16> {
185 if constraints.is_empty() {
186 return Vec::new();
187 }
188
189 let total_seps = constraints.len().saturating_sub(1) as u16;
190 let available = width.saturating_sub(total_seps);
191
192 let widths: Vec<u16> = constraints
193 .iter()
194 .map(|c| match c {
195 Constraint::Length(n) | Constraint::Min(n) | Constraint::Max(n) => (*n).min(available),
196 Constraint::Percentage(p) => (available as u32 * (*p).min(100) as u32 / 100) as u16,
197 Constraint::Ratio(num, den) => {
198 if *den == 0 {
199 0
200 } else {
201 (available as u32 * *num / *den) as u16
202 }
203 }
204 Constraint::Fill(_) => 0,
205 })
206 .collect();
207
208 let allocated: u16 = widths.iter().sum();
210 let remaining = available.saturating_sub(allocated);
211 let fill_count = constraints
212 .iter()
213 .filter(|c| matches!(c, Constraint::Fill(_)))
214 .count() as u16;
215
216 let widths: Vec<u16> = if fill_count > 0 {
217 let fill_each = remaining / fill_count.max(1);
218
219 widths
220 .iter()
221 .zip(constraints)
222 .map(|(w, c)| {
223 if matches!(c, Constraint::Fill(_)) {
224 fill_each
225 } else {
226 *w
227 }
228 })
229 .collect()
230 } else {
231 widths
232 };
233
234 let mut offsets = Vec::with_capacity(constraints.len().saturating_sub(1));
236 let mut x = 0u16;
237
238 for (i, w) in widths.iter().enumerate() {
239 x += w;
240
241 if i < widths.len() - 1 {
242 offsets.push(x);
243 x += 1; }
245 }
246
247 offsets
248}
249
250fn column_ranges(offsets: &[u16], total_width: u16) -> Vec<(u16, u16)> {
252 if offsets.is_empty() {
253 return vec![(0, total_width)];
254 }
255
256 let mut ranges = Vec::with_capacity(offsets.len() + 1);
257 let mut start = 0u16;
258
259 for &sep in offsets {
260 ranges.push((start, sep.saturating_sub(start)));
261 start = sep + 1; }
263
264 ranges.push((start, total_width.saturating_sub(start)));
265 ranges
266}
267
268#[cfg(feature = "pantry")]
269#[path = "table.ingredient.rs"]
270pub mod ingredient;
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn renders_correct_row_count() {
278 let area = Rect::new(0, 0, 20, 10);
279 let mut buf = Buffer::empty(area);
280
281 SkeletonTable::new(1000).rows(3).render(area, &mut buf);
282
283 assert_ne!(buf[(0, 0)].symbol(), " ");
285 assert_ne!(buf[(0, 2)].symbol(), " ");
286 assert_eq!(buf[(0, 3)].symbol(), " ");
287 }
288
289 #[test]
290 fn column_separators_present() {
291 let cols = [Constraint::Length(5), Constraint::Length(5)];
292 let area = Rect::new(0, 0, 11, 3);
293 let mut buf = Buffer::empty(area);
294
295 SkeletonTable::new(1000)
296 .columns(&cols)
297 .rows(3)
298 .render(area, &mut buf);
299
300 assert_eq!(buf[(5, 0)].symbol(), "│");
302 }
303
304 #[test]
305 fn cells_have_ragged_widths() {
306 let cols = [Constraint::Length(10), Constraint::Length(10)];
307 let area = Rect::new(0, 0, 21, 2);
308 let mut buf = Buffer::empty(area);
309
310 SkeletonTable::new(1000)
312 .columns(&cols)
313 .rows(2)
314 .cell_widths(&[0.5, 1.0])
315 .zebra(false)
316 .render(area, &mut buf);
317
318 assert_ne!(buf[(0, 0)].symbol(), " ");
320 assert_eq!(buf[(5, 0)].symbol(), " "); assert_ne!(buf[(11, 0)].symbol(), " "); assert_ne!(buf[(20, 0)].symbol(), " ");
325 }
326
327 #[test]
328 fn empty_area_is_noop() {
329 let area = Rect::new(0, 0, 0, 0);
330 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
331 let expected = buf.clone();
332
333 SkeletonTable::new(0).render(area, &mut buf);
334
335 assert_eq!(buf, expected);
336 }
337
338 #[test]
339 fn resolve_percentage_columns() {
340 let constraints = [Constraint::Percentage(50), Constraint::Percentage(50)];
341 let offsets = resolve_columns(&constraints, 21);
342
343 assert_eq!(offsets, vec![10]);
345 }
346}