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