alopex_cli/tui/
table.rs

1//! Table view widget for TUI.
2
3use ratatui::layout::{Constraint, Rect};
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::widgets::{Block, Borders, Cell, Row as TuiRow, Table, TableState};
6use ratatui::Frame;
7
8use crate::models::{Column, Row, Value};
9
10use super::search::SearchState;
11
12const MAX_CELL_WIDTH: usize = 32;
13
14/// Scrollable table view state.
15pub struct TableView {
16    columns: Vec<Column>,
17    rows: Vec<Row>,
18    state: TableState,
19    row_offset: usize,
20    col_offset: usize,
21    view_height: usize,
22}
23
24impl TableView {
25    pub fn new(columns: Vec<Column>, rows: Vec<Row>) -> Self {
26        let mut state = TableState::default();
27        if !rows.is_empty() {
28            state.select(Some(0));
29        }
30        Self {
31            columns,
32            rows,
33            state,
34            row_offset: 0,
35            col_offset: 0,
36            view_height: 10,
37        }
38    }
39
40    pub fn columns(&self) -> &[Column] {
41        &self.columns
42    }
43
44    pub fn rows(&self) -> &[Row] {
45        &self.rows
46    }
47
48    pub fn selected_row(&self) -> Option<&Row> {
49        let selected = self.state.selected()?;
50        self.rows.get(selected)
51    }
52
53    #[allow(dead_code)]
54    pub fn selected_index(&self) -> Option<usize> {
55        self.state.selected()
56    }
57
58    pub fn select_row(&mut self, index: usize) {
59        if self.rows.is_empty() {
60            return;
61        }
62        let index = index.min(self.rows.len() - 1);
63        self.state.select(Some(index));
64        self.ensure_visible(index);
65    }
66
67    pub fn move_up(&mut self) {
68        if self.rows.is_empty() {
69            return;
70        }
71        let selected = self.state.selected().unwrap_or(0);
72        let next = selected.saturating_sub(1);
73        self.state.select(Some(next));
74        self.ensure_visible(next);
75    }
76
77    pub fn move_down(&mut self) {
78        if self.rows.is_empty() {
79            return;
80        }
81        let selected = self.state.selected().unwrap_or(0);
82        let next = (selected + 1).min(self.rows.len().saturating_sub(1));
83        self.state.select(Some(next));
84        self.ensure_visible(next);
85    }
86
87    pub fn move_left(&mut self) {
88        self.col_offset = self.col_offset.saturating_sub(1);
89    }
90
91    pub fn move_right(&mut self) {
92        if self.col_offset + 1 < self.columns.len() {
93            self.col_offset += 1;
94        }
95    }
96
97    pub fn page_up(&mut self) {
98        if self.rows.is_empty() {
99            return;
100        }
101        let selected = self.state.selected().unwrap_or(0);
102        let max_visible = self.view_height.saturating_sub(3).max(1);
103        let delta = (max_visible / 2).max(1);
104        let next = selected.saturating_sub(delta);
105        self.state.select(Some(next));
106        self.ensure_visible(next);
107    }
108
109    pub fn page_down(&mut self) {
110        if self.rows.is_empty() {
111            return;
112        }
113        let selected = self.state.selected().unwrap_or(0);
114        let max_visible = self.view_height.saturating_sub(3).max(1);
115        let delta = (max_visible / 2).max(1);
116        let next = (selected + delta).min(self.rows.len().saturating_sub(1));
117        self.state.select(Some(next));
118        self.ensure_visible(next);
119    }
120
121    pub fn jump_top(&mut self) {
122        self.state.select(Some(0));
123        self.row_offset = 0;
124    }
125
126    pub fn jump_bottom(&mut self) {
127        if self.rows.is_empty() {
128            return;
129        }
130        let last = self.rows.len() - 1;
131        self.state.select(Some(last));
132        self.ensure_visible(last);
133    }
134
135    pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect, search: &SearchState) {
136        self.view_height = area.height as usize;
137        let columns = self.visible_columns();
138        let header_cells = columns
139            .iter()
140            .map(|col| Cell::from(col.name.clone()).style(header_style()));
141        let mut header = vec![Cell::from("#").style(header_style())];
142        header.extend(header_cells);
143
144        let row_range = self.visible_row_range();
145        let rows = self.rows[row_range.clone()]
146            .iter()
147            .enumerate()
148            .map(|(idx, row)| {
149                let row_index = self.row_offset + idx;
150                let mut cells = Vec::with_capacity(columns.len() + 1);
151                cells.push(Cell::from((row_index + 1).to_string()));
152                for (col_index, value) in row.columns.iter().enumerate().skip(self.col_offset) {
153                    if col_index >= self.col_offset + columns.len() {
154                        break;
155                    }
156                    let text = format_value(value);
157                    let mut cell = Cell::from(text.clone());
158                    if search.matches_cell(row_index, col_index, &text) {
159                        cell = cell.style(Style::default().fg(Color::Yellow));
160                    }
161                    cells.push(cell);
162                }
163                TuiRow::new(cells)
164            });
165
166        let widths = self.column_widths(&columns);
167        let mut constraints = Vec::with_capacity(widths.len() + 1);
168        constraints.push(Constraint::Length(4));
169        constraints.extend(widths.into_iter().map(|w| Constraint::Length(w as u16)));
170
171        let table = Table::new(rows, constraints)
172            .header(TuiRow::new(header))
173            .block(Block::default().borders(Borders::ALL).title("Results"))
174            .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
175            .highlight_symbol("▌");
176
177        frame.render_stateful_widget(table, area, &mut self.state);
178    }
179
180    fn visible_row_range(&self) -> std::ops::Range<usize> {
181        if self.rows.is_empty() {
182            return 0..0;
183        }
184        let max_rows = self.view_height.saturating_sub(3).max(1);
185        let end = (self.row_offset + max_rows).min(self.rows.len());
186        self.row_offset..end
187    }
188
189    fn ensure_visible(&mut self, row: usize) {
190        if row < self.row_offset {
191            self.row_offset = row;
192        }
193        let max_visible = self.view_height.saturating_sub(3).max(1);
194        if row >= self.row_offset + max_visible {
195            self.row_offset = row.saturating_sub(max_visible - 1);
196        }
197    }
198
199    fn visible_columns(&self) -> Vec<Column> {
200        if self.columns.is_empty() {
201            return Vec::new();
202        }
203        let start = self.col_offset.min(self.columns.len() - 1);
204        self.columns[start..].to_vec()
205    }
206
207    fn column_widths(&self, columns: &[Column]) -> Vec<usize> {
208        columns
209            .iter()
210            .enumerate()
211            .map(|(idx, col)| {
212                let width = col.name.len();
213                let mut max_width = width;
214                for row in &self.rows {
215                    if let Some(value) = row.columns.get(self.col_offset + idx) {
216                        let value_len = format_value(value).len();
217                        max_width = max_width.max(value_len);
218                    }
219                }
220                max_width.clamp(4, MAX_CELL_WIDTH)
221            })
222            .collect()
223    }
224}
225
226fn header_style() -> Style {
227    Style::default()
228        .fg(Color::Cyan)
229        .add_modifier(Modifier::BOLD)
230}
231
232pub fn format_value(value: &Value) -> String {
233    match value {
234        Value::Null => "NULL".to_string(),
235        Value::Bool(b) => b.to_string(),
236        Value::Int(i) => i.to_string(),
237        Value::Float(f) => format!("{:.6}", f),
238        Value::Text(s) => s.clone(),
239        Value::Bytes(b) => {
240            let hex: String = b
241                .iter()
242                .take(32)
243                .map(|byte| format!("{:02x}", byte))
244                .collect();
245            if b.len() > 32 {
246                format!("{}...", hex)
247            } else {
248                hex
249            }
250        }
251        Value::Vector(v) => {
252            if v.len() <= 4 {
253                format!(
254                    "[{}]",
255                    v.iter()
256                        .map(|x| format!("{:.4}", x))
257                        .collect::<Vec<_>>()
258                        .join(", ")
259                )
260            } else {
261                format!(
262                    "[{}, ... ({} dims)]",
263                    v.iter()
264                        .take(3)
265                        .map(|x| format!("{:.4}", x))
266                        .collect::<Vec<_>>()
267                        .join(", "),
268                    v.len()
269                )
270            }
271        }
272    }
273}