Skip to main content

tui_skeleton/
table.rs

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
11/// Deterministic cell width fractions — cycles across (row, col) pairs.
12const 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/// Skeleton table with rows, column separators, and optional zebra striping.
17///
18/// Column widths are specified as [`Constraint`] slices, matching how
19/// ratatui tables define their layouts. Each cell fills only a fraction
20/// of its column width (set via [`cell_widths`](SkeletonTable::cell_widths)),
21/// mimicking ragged text content.
22#[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
37/// Brightness offset applied to odd rows when zebra striping is enabled.
38const 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    /// Per-cell width fractions (`0.0..=1.0`) cycling across `(row, col)`.
87    ///
88    /// Each cell renders only this fraction of its column width,
89    /// producing ragged edges that mimic real text data. Default
90    /// uses a built-in 11-element pattern.
91    pub fn cell_widths(mut self, widths: &'a [f32]) -> Self {
92        self.cell_widths = widths;
93        self
94    }
95
96    /// Enable or disable alternating row brightness. Default: `true`.
97    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            // Render separators.
141            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            // Render cell content with per-cell width variation.
150            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
181/// Resolve `Constraint` slices to absolute column boundary offsets.
182///
183/// Returns the x-offsets where column separators should appear (between columns).
184fn 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    // Distribute remaining space to Fill columns, or evenly if none specified.
209    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    // Convert widths to separator offsets.
235    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; // separator column
244        }
245    }
246
247    offsets
248}
249
250/// Derive `(start_col, width)` ranges for each column from separator offsets.
251fn 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; // skip separator
262    }
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        // Row 3 (0-indexed) should be empty.
284        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        // Separator at column 5.
301        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        // Force predictable widths: first cell 50%, second cell 100%.
311        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        // First cell of row 0: 50% of 10 = 5 filled, cell 5 should be empty.
319        assert_ne!(buf[(0, 0)].symbol(), " ");
320        assert_eq!(buf[(5, 0)].symbol(), " "); // unfilled tail
321
322        // Second cell of row 0: 100% of 10 = all filled.
323        assert_ne!(buf[(11, 0)].symbol(), " "); // after separator at col 10
324        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        // 21 - 1 separator = 20 available; 50% each = 10; separator at offset 10.
344        assert_eq!(offsets, vec![10]);
345    }
346}