sql_cli/
virtual_table.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Rect},
4    style::{Color, Modifier, Style},
5    widgets::{Block, Row, StatefulWidget, Table, Widget},
6};
7use serde_json::Value;
8
9/// A table widget that only renders visible rows for performance
10pub struct VirtualTable<'a> {
11    headers: Vec<&'a str>,
12    data: &'a [Value],
13    widths: Vec<Constraint>,
14    block: Option<Block<'a>>,
15    header_style: Style,
16    row_style: Style,
17    highlight_style: Style,
18    highlight_symbol: &'a str,
19}
20
21impl<'a> VirtualTable<'a> {
22    #[must_use]
23    pub fn new(headers: Vec<&'a str>, data: &'a [Value], widths: Vec<Constraint>) -> Self {
24        Self {
25            headers,
26            data,
27            widths,
28            block: None,
29            header_style: Style::default().fg(Color::Yellow),
30            row_style: Style::default(),
31            highlight_style: Style::default().add_modifier(Modifier::REVERSED),
32            highlight_symbol: ">> ",
33        }
34    }
35
36    #[must_use]
37    pub fn block(mut self, block: Block<'a>) -> Self {
38        self.block = Some(block);
39        self
40    }
41
42    #[must_use]
43    pub fn header_style(mut self, style: Style) -> Self {
44        self.header_style = style;
45        self
46    }
47
48    #[must_use]
49    pub fn highlight_style(mut self, style: Style) -> Self {
50        self.highlight_style = style;
51        self
52    }
53}
54
55#[derive(Default, Clone)]
56pub struct VirtualTableState {
57    /// Current offset (first visible row)
58    pub offset: usize,
59    /// Currently selected row (absolute index)
60    pub selected: usize,
61    /// Number of visible rows (calculated during render)
62    pub visible_rows: usize,
63}
64
65impl VirtualTableState {
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn select(&mut self, index: usize) {
72        self.selected = index;
73    }
74
75    pub fn scroll_up(&mut self, amount: usize) {
76        self.selected = self.selected.saturating_sub(amount);
77
78        // If we're moving within the visible window, no need to adjust offset
79        if self.selected >= self.offset && self.selected < self.offset + self.visible_rows {
80            return;
81        }
82
83        // If selected moved above the visible area, scroll up
84        if self.selected < self.offset {
85            self.offset = self.selected;
86        }
87    }
88
89    pub fn scroll_down(&mut self, amount: usize, total_rows: usize) {
90        self.selected = (self.selected + amount).min(total_rows.saturating_sub(1));
91
92        // If we're moving within the visible window, no need to adjust offset
93        if self.selected >= self.offset && self.selected < self.offset + self.visible_rows {
94            return;
95        }
96
97        // If selected moved below the visible area, scroll down
98        if self.selected >= self.offset + self.visible_rows {
99            self.offset = self.selected.saturating_sub(self.visible_rows - 1);
100        }
101    }
102
103    pub fn page_up(&mut self) {
104        let page_size = self.visible_rows.saturating_sub(1);
105        self.scroll_up(page_size);
106    }
107
108    pub fn page_down(&mut self, total_rows: usize) {
109        let page_size = self.visible_rows.saturating_sub(1);
110        self.scroll_down(page_size, total_rows);
111    }
112
113    pub fn goto_top(&mut self) {
114        self.selected = 0;
115        self.offset = 0;
116    }
117
118    pub fn goto_bottom(&mut self, total_rows: usize) {
119        self.selected = total_rows.saturating_sub(1);
120        // Position the viewport so the last page is visible
121        // This ensures the cursor is at the bottom row of a full viewport
122        if total_rows > self.visible_rows {
123            self.offset = total_rows.saturating_sub(self.visible_rows);
124        } else {
125            self.offset = 0;
126        }
127    }
128}
129
130impl StatefulWidget for VirtualTable<'_> {
131    type State = VirtualTableState;
132
133    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
134        // Calculate the inner area (accounting for borders)
135        let inner_area = if let Some(ref block) = self.block {
136            let inner = block.inner(area);
137            block.render(area, buf);
138            inner
139        } else {
140            area
141        };
142
143        // Calculate how many rows we can display (account for header)
144        let available_height = inner_area.height.saturating_sub(2); // 1 for header, 1 for header margin
145        state.visible_rows = available_height as usize;
146
147        // Only create rows for visible data
148        let end_row = (state.offset + state.visible_rows).min(self.data.len());
149        let visible_slice = &self.data[state.offset..end_row];
150
151        // Create header
152        let header_cells: Vec<ratatui::widgets::Cell> = self
153            .headers
154            .iter()
155            .map(|h| ratatui::widgets::Cell::from(*h).style(self.header_style))
156            .collect();
157        let header = Row::new(header_cells).height(1).bottom_margin(1);
158
159        // Create only visible rows
160        let rows: Vec<Row> = visible_slice
161            .iter()
162            .enumerate()
163            .map(|(idx, record)| {
164                let absolute_idx = state.offset + idx;
165                let is_selected = absolute_idx == state.selected;
166
167                let cells: Vec<ratatui::widgets::Cell> = self
168                    .headers
169                    .iter()
170                    .map(|field| {
171                        if let Some(obj) = record.as_object() {
172                            match obj.get(*field) {
173                                Some(Value::String(s)) => ratatui::widgets::Cell::from(s.as_str()),
174                                Some(Value::Number(n)) => {
175                                    ratatui::widgets::Cell::from(n.to_string())
176                                }
177                                Some(Value::Bool(b)) => ratatui::widgets::Cell::from(b.to_string()),
178                                Some(Value::Null) => ratatui::widgets::Cell::from("NULL")
179                                    .style(Style::default().fg(Color::Gray)),
180                                Some(v) => ratatui::widgets::Cell::from(v.to_string()),
181                                None => ratatui::widgets::Cell::from(""),
182                            }
183                        } else {
184                            ratatui::widgets::Cell::from("")
185                        }
186                    })
187                    .collect();
188
189                let mut row = Row::new(cells).height(1);
190                if is_selected {
191                    row = row.style(self.highlight_style);
192                }
193                row
194            })
195            .collect();
196
197        // Calculate selected row relative to visible area
198        let relative_selected = if state.selected >= state.offset && state.selected < end_row {
199            Some(state.selected - state.offset)
200        } else {
201            None
202        };
203
204        // Create a minimal table state for rendering
205        let mut table_state = ratatui::widgets::TableState::default();
206        table_state.select(relative_selected);
207
208        // Render the table with only visible rows
209        let table = Table::new(rows, self.widths.clone())
210            .header(header)
211            .highlight_symbol(self.highlight_symbol);
212
213        <Table as StatefulWidget>::render(table, inner_area, buf, &mut table_state);
214    }
215}