Skip to main content

tui_kit/
kv.rs

1use ratatui::{
2    layout::{Alignment, Constraint, Direction, Layout, Rect},
3    text::{Line, Span},
4    widgets::Paragraph,
5    Frame,
6};
7
8use crate::{block::{focusable_block, render_scrollbar}, Theme};
9
10/// A single row in a [`render_kv_table`] widget.
11pub enum KvRow {
12    /// A section header rendered full-width in [`Theme::section_header`] style.
13    Header(String),
14    /// A key-value pair: key right-aligned, value left-aligned.
15    Field { key: String, value: String },
16    /// A blank separator line.
17    Separator,
18}
19
20/// Render a scrollable key-value table inside `area`.
21///
22/// - Section [`KvRow::Header`]s are rendered full-width in [`Theme::section_header`].
23/// - [`KvRow::Field`] rows split the inner width: key column (right-aligned, [`Theme::hint`])
24///   and value column (left-aligned, [`Theme::body`]). The key column width is the minimum
25///   of the longest key in `rows` and 30% of the inner width.
26/// - [`KvRow::Separator`] renders a blank line.
27/// - `scroll` skips the first N logical rows before rendering.
28pub fn render_kv_table(
29    f: &mut Frame,
30    area: Rect,
31    title: &str,
32    shortcut: Option<u8>,
33    rows: &[KvRow],
34    scroll: u16,
35    focused: bool,
36    theme: &Theme,
37) {
38    let block = focusable_block(title, shortcut, focused, theme);
39    let inner = block.inner(area);
40    f.render_widget(block, area);
41    render_scrollbar(f, area, rows.len(), scroll as usize);
42
43    if inner.height == 0 || rows.is_empty() {
44        return;
45    }
46
47    // Compute key column width: longest key, capped at 30% of inner width.
48    let max_key_len = rows
49        .iter()
50        .filter_map(|r| match r {
51            KvRow::Field { key, .. } => Some(key.chars().count()),
52            _ => None,
53        })
54        .max()
55        .unwrap_or(0);
56    let key_col_width = (max_key_len as u16).min((inner.width as f32 * 0.30) as u16).max(1);
57
58    let visible_height = inner.height as usize;
59    let skip = scroll as usize;
60
61    for (i, row) in rows.iter().skip(skip).take(visible_height).enumerate() {
62        let row_y = inner.y + i as u16;
63        let row_area = Rect { x: inner.x, y: row_y, width: inner.width, height: 1 };
64
65        match row {
66            KvRow::Header(text) => {
67                let para = Paragraph::new(Line::from(Span::styled(
68                    text.clone(),
69                    theme.section_header,
70                )));
71                f.render_widget(para, row_area);
72            }
73            KvRow::Separator => {
74                // Blank line — nothing to render.
75            }
76            KvRow::Field { key, value } => {
77                let val_col_width = inner.width.saturating_sub(key_col_width + 1); // +1 for gap
78
79                let chunks = Layout::default()
80                    .direction(Direction::Horizontal)
81                    .constraints([
82                        Constraint::Length(key_col_width),
83                        Constraint::Length(1), // gap
84                        Constraint::Length(val_col_width),
85                    ])
86                    .split(row_area);
87
88                let key_para = Paragraph::new(Line::from(Span::styled(
89                    key.clone(),
90                    theme.hint,
91                )))
92                .alignment(Alignment::Right);
93
94                let val_para = Paragraph::new(Line::from(Span::styled(
95                    value.clone(),
96                    theme.body,
97                )));
98
99                f.render_widget(key_para, chunks[0]);
100                f.render_widget(val_para, chunks[2]);
101            }
102        }
103    }
104}