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::{cell_intensity, interpolate_color, AnimationMode};
9use crate::defaults;
10
11/// Skeleton table with rows, column separators, and optional zebra striping.
12///
13/// Column widths are specified as [`Constraint`] slices, matching how
14/// ratatui tables define their layouts.
15#[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
28/// Brightness offset applied to odd rows when zebra striping is enabled.
29const 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    /// Enable or disable alternating row brightness. Default: `true`.
71    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        // Breathe is uniform — hoist outside the loop.
99        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                // Column separators.
117                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
136/// Resolve `Constraint` slices to absolute column boundary offsets.
137///
138/// Returns the x-offsets where column separators should appear (between columns).
139fn 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    // Distribute remaining space to Fill columns, or evenly if none specified.
164    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    // Convert widths to separator offsets.
190    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; // separator column
199        }
200    }
201
202    offsets
203}
204
205fn is_separator(offsets: &[u16], col: u16) -> bool {
206    offsets.contains(&col)
207}
208
209#[cfg(feature = "pantry")]
210#[path = "table.ingredient.rs"]
211pub mod ingredient;
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn renders_correct_row_count() {
219        let area = Rect::new(0, 0, 20, 10);
220        let mut buf = Buffer::empty(area);
221
222        SkeletonTable::new(1000).rows(3).render(area, &mut buf);
223
224        // Row 3 (0-indexed) should be empty.
225        assert_ne!(buf[(0, 0)].symbol(), " ");
226        assert_ne!(buf[(0, 2)].symbol(), " ");
227        assert_eq!(buf[(0, 3)].symbol(), " ");
228    }
229
230    #[test]
231    fn column_separators_present() {
232        let cols = [Constraint::Length(5), Constraint::Length(5)];
233        let area = Rect::new(0, 0, 11, 3);
234        let mut buf = Buffer::empty(area);
235
236        SkeletonTable::new(1000)
237            .columns(&cols)
238            .rows(3)
239            .render(area, &mut buf);
240
241        // Separator at column 5.
242        assert_eq!(buf[(5, 0)].symbol(), "│");
243    }
244
245    #[test]
246    fn empty_area_is_noop() {
247        let area = Rect::new(0, 0, 0, 0);
248        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
249        let expected = buf.clone();
250
251        SkeletonTable::new(0).render(area, &mut buf);
252
253        assert_eq!(buf, expected);
254    }
255
256    #[test]
257    fn resolve_percentage_columns() {
258        let constraints = [Constraint::Percentage(50), Constraint::Percentage(50)];
259        let offsets = resolve_columns(&constraints, 21);
260
261        // 21 - 1 separator = 20 available; 50% each = 10; separator at offset 10.
262        assert_eq!(offsets, vec![10]);
263    }
264}