Skip to main content

tui_skeleton/
kv_table.rs

1use ratatui_core::{
2    buffer::Buffer,
3    layout::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 value width fractions cycling across rows.
12const DEFAULT_VALUE_WIDTHS: [f32; 5] = [0.60, 0.40, 0.75, 0.35, 0.55];
13
14/// Skeleton key-value table (properties panel / detail view).
15///
16/// Renders pairs of short fixed-width keys on the left and
17/// variable-width values on the right, separated by a dim `│`.
18/// Each pair occupies one row with a gap row between pairs.
19#[must_use]
20#[derive(Debug, Clone)]
21pub struct SkeletonKvTable<'a> {
22    elapsed_ms: u64,
23    mode: AnimationMode,
24    braille: bool,
25    base: Color,
26    highlight: Color,
27    pairs: u16,
28    key_width: u16,
29    value_widths: &'a [f32],
30    block: Option<ratatui_widgets::block::Block<'a>>,
31}
32
33impl<'a> SkeletonKvTable<'a> {
34    pub fn new(elapsed_ms: u64) -> Self {
35        Self {
36            elapsed_ms,
37            mode: AnimationMode::default(),
38            braille: false,
39            base: defaults::BASE,
40            highlight: defaults::HIGHLIGHT,
41            pairs: 5,
42            key_width: 12,
43            value_widths: &DEFAULT_VALUE_WIDTHS,
44            block: None,
45        }
46    }
47
48    pub fn mode(mut self, mode: AnimationMode) -> Self {
49        self.mode = mode;
50        self
51    }
52
53    pub fn braille(mut self, braille: bool) -> Self {
54        self.braille = braille;
55        self
56    }
57
58    pub fn base(mut self, color: impl Into<Color>) -> Self {
59        self.base = color.into();
60        self
61    }
62
63    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
64        self.highlight = color.into();
65        self
66    }
67
68    /// Number of key-value pairs. Default: `5`.
69    pub fn pairs(mut self, pairs: u16) -> Self {
70        self.pairs = pairs;
71        self
72    }
73
74    /// Fixed width of the key column in cells. Default: `12`.
75    pub fn key_width(mut self, width: u16) -> Self {
76        self.key_width = width;
77        self
78    }
79
80    /// Per-pair value width fractions (`0.0..=1.0`) of the remaining space.
81    ///
82    /// The pattern cycles when there are more pairs than entries.
83    pub fn value_widths(mut self, widths: &'a [f32]) -> Self {
84        self.value_widths = widths;
85        self
86    }
87
88    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
89        self.block = Some(block);
90        self
91    }
92}
93
94impl Widget for SkeletonKvTable<'_> {
95    fn render(self, area: Rect, buf: &mut Buffer) {
96        let inner = if let Some(ref block) = self.block {
97            let inner_area = block.inner(area);
98            block.render(area, buf);
99            inner_area
100        } else {
101            area
102        };
103
104        // key_width + separator(1) + gap(1) + at least 1 value cell
105        if inner.is_empty() || inner.width < self.key_width + 3 || self.value_widths.is_empty() {
106            return;
107        }
108
109        let sep_col = self.key_width;
110        let value_start = sep_col + 2; // separator + 1 gap
111        let value_space = inner.width - value_start;
112
113        let stride = 2u16; // content row + gap row
114        let pair_count = self.pairs.min((inner.height + 1) / stride);
115
116        let uniform_t = is_uniform(self.mode)
117            .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
118
119        for i in 0..pair_count {
120            let y = inner.y + i * stride;
121            let row = y - inner.y;
122
123            if y >= inner.bottom() {
124                break;
125            }
126
127            // Key cells.
128            for col in 0..self.key_width {
129                let x = inner.x + col;
130                let t = uniform_t.unwrap_or_else(|| {
131                    cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
132                });
133                let fg = interpolate_color(self.base, self.highlight, self.mode, t);
134                let glyph = crate::animation::cell_glyph(
135                    self.braille,
136                    self.mode,
137                    self.elapsed_ms,
138                    row,
139                    col,
140                );
141
142                buf[(x, y)]
143                    .set_char(glyph)
144                    .set_style(Style::default().fg(fg));
145            }
146
147            // Separator.
148            buf[(inner.x + sep_col, y)]
149                .set_char('│')
150                .set_style(Style::default().fg(self.base));
151
152            // Value cells.
153            let frac = self.value_widths[i as usize % self.value_widths.len()].clamp(0.0, 1.0);
154            let val_width = ((value_space as f32) * frac).ceil() as u16;
155
156            for col in 0..val_width.min(value_space) {
157                let abs_col = value_start + col;
158                let x = inner.x + abs_col;
159                let t = uniform_t.unwrap_or_else(|| {
160                    cell_intensity(self.mode, self.elapsed_ms, abs_col, inner.width)
161                });
162                let fg = interpolate_color(self.base, self.highlight, self.mode, t);
163                let glyph = crate::animation::cell_glyph(
164                    self.braille,
165                    self.mode,
166                    self.elapsed_ms,
167                    row,
168                    abs_col,
169                );
170
171                buf[(x, y)]
172                    .set_char(glyph)
173                    .set_style(Style::default().fg(fg));
174            }
175        }
176    }
177}
178
179#[cfg(feature = "pantry")]
180#[path = "kv_table.ingredient.rs"]
181pub mod ingredient;
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn separator_between_key_and_value() {
189        let area = Rect::new(0, 0, 30, 3);
190        let mut buf = Buffer::empty(area);
191
192        SkeletonKvTable::new(1000)
193            .pairs(1)
194            .key_width(8)
195            .render(area, &mut buf);
196
197        // Key fills cols 0..8.
198        assert_eq!(buf[(0, 0)].symbol(), "█");
199        assert_eq!(buf[(7, 0)].symbol(), "█");
200
201        // Separator at col 8.
202        assert_eq!(buf[(8, 0)].symbol(), "│");
203
204        // Gap at col 9, value starts at col 10.
205        assert_eq!(buf[(9, 0)].symbol(), " ");
206        assert_eq!(buf[(10, 0)].symbol(), "█");
207    }
208
209    #[test]
210    fn pairs_have_gaps() {
211        let area = Rect::new(0, 0, 30, 4);
212        let mut buf = Buffer::empty(area);
213
214        SkeletonKvTable::new(1000)
215            .pairs(2)
216            .key_width(5)
217            .render(area, &mut buf);
218
219        // Row 0: content.
220        assert_eq!(buf[(0, 0)].symbol(), "█");
221
222        // Row 1: gap.
223        assert_eq!(buf[(0, 1)].symbol(), " ");
224
225        // Row 2: content.
226        assert_eq!(buf[(0, 2)].symbol(), "█");
227    }
228
229    #[test]
230    fn too_narrow_is_noop() {
231        let area = Rect::new(0, 0, 5, 5);
232        let mut buf = Buffer::empty(area);
233        let expected = buf.clone();
234
235        SkeletonKvTable::new(1000)
236            .key_width(10)
237            .render(area, &mut buf);
238
239        assert_eq!(buf, expected);
240    }
241}