use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Paragraph, Row as RatRow, Table, TableState},
Frame,
};
use crate::db::types::{DbQueryResult, Value};
const COLLAPSED_WIDTH: u16 = 3;
const MAX_COL_WIDTH: u16 = 25;
pub enum DataGridAction {
None,
Back,
}
pub struct DataGridScreen {
pub table_name: String,
pub result: Option<DbQueryResult>,
pub table_state: TableState,
pub selected_col: usize,
pub col_offset: usize,
pub collapsed_cols: HashSet<usize>,
pub status: Option<String>,
}
impl DataGridScreen {
pub fn new(table_name: String) -> Self {
let mut table_state = TableState::default();
table_state.select(Some(0));
Self {
table_name,
result: None,
table_state,
selected_col: 0,
col_offset: 0,
collapsed_cols: HashSet::new(),
status: Some("Loading…".into()),
}
}
pub fn set_result(&mut self, result: DbQueryResult) {
self.status = None;
self.selected_col = 0;
self.col_offset = 0;
self.collapsed_cols.clear();
self.result = Some(result);
self.table_state.select(Some(0));
}
pub fn set_error(&mut self, msg: String) {
self.status = Some(format!("Error: {msg}"));
}
fn row_count(&self) -> usize {
self.result.as_ref().map_or(0, |r| r.rows.len())
}
fn col_count(&self) -> usize {
self.result.as_ref().map_or(0, |r| r.columns.len())
}
fn selected_row(&self) -> usize {
self.table_state.selected().unwrap_or(0)
}
fn effective_col_width(&self, col_idx: usize) -> u16 {
if self.collapsed_cols.contains(&col_idx) {
return COLLAPSED_WIDTH;
}
let Some(ref result) = self.result else { return 10; };
let header_w = result.columns[col_idx].name.len() as u16;
let max_val_w = result
.rows
.iter()
.map(|r| value_display(r.values.get(col_idx).unwrap_or(&Value::Null)).len() as u16)
.max()
.unwrap_or(0);
(header_w.max(max_val_w) + 2).min(MAX_COL_WIDTH)
}
fn visible_columns(&self, available_width: u16) -> Vec<usize> {
let col_count = self.col_count();
let mut visible = vec![];
let mut used = 1u16; for i in self.col_offset..col_count {
let w = self.effective_col_width(i);
used += w + 1; if used > available_width {
break;
}
visible.push(i);
}
visible
}
pub fn handle_key(&mut self, key: KeyEvent) -> DataGridAction {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => DataGridAction::Back,
KeyCode::Char('j') | KeyCode::Down => { self.move_row(1); DataGridAction::None }
KeyCode::Char('k') | KeyCode::Up => { self.move_row(-1); DataGridAction::None }
KeyCode::Char('l') | KeyCode::Right => { self.move_col(1); DataGridAction::None }
KeyCode::Char('h') | KeyCode::Left => { self.move_col(-1); DataGridAction::None }
KeyCode::Char('g') => { self.go_first(); DataGridAction::None }
KeyCode::Char('G') => { self.go_last(); DataGridAction::None }
KeyCode::PageDown => { self.move_row(10); DataGridAction::None }
KeyCode::PageUp => { self.move_row(-10); DataGridAction::None }
KeyCode::Char(' ') => { self.toggle_collapse(); DataGridAction::None }
_ => DataGridAction::None,
}
}
fn move_row(&mut self, delta: i64) {
let count = self.row_count();
if count == 0 { return; }
let next = (self.selected_row() as i64 + delta).clamp(0, count as i64 - 1) as usize;
self.table_state.select(Some(next));
}
fn move_col(&mut self, delta: i64) {
let count = self.col_count();
if count == 0 { return; }
let next = (self.selected_col as i64 + delta).clamp(0, count as i64 - 1) as usize;
self.selected_col = next;
if next < self.col_offset {
self.col_offset = next;
}
}
fn go_first(&mut self) {
if self.row_count() > 0 {
self.table_state.select(Some(0));
}
}
fn go_last(&mut self) {
let count = self.row_count();
if count > 0 {
self.table_state.select(Some(count - 1));
}
}
fn toggle_collapse(&mut self) {
if self.col_count() == 0 { return; }
if self.collapsed_cols.contains(&self.selected_col) {
self.collapsed_cols.remove(&self.selected_col);
} else {
self.collapsed_cols.insert(self.selected_col);
}
}
pub fn draw(f: &mut Frame<'_>, screen: &mut DataGridScreen) {
let area = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(3), ])
.split(area);
let info = if let Some(ref r) = screen.result {
let row_info = if r.rows.is_empty() {
"no rows".to_string()
} else {
format!("row {}/{}", screen.selected_row() + 1, r.rows.len())
};
format!(
" {} │ {} │ col {}/{} (LIMIT 1000) ",
screen.table_name,
row_info,
screen.selected_col + 1,
r.columns.len(),
)
} else {
format!(" {} ", screen.table_name)
};
f.render_widget(
Paragraph::new(info)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
chunks[0],
);
if screen.selected_col < screen.col_offset {
screen.col_offset = screen.selected_col;
}
loop {
let visible = screen.visible_columns(chunks[1].width);
if visible.is_empty() || *visible.last().unwrap() >= screen.selected_col {
break;
}
screen.col_offset += 1;
}
let visible = screen.visible_columns(chunks[1].width);
match &screen.result {
None => {
let msg = screen.status.clone().unwrap_or_else(|| "No data".into());
f.render_widget(
Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
Some(result) if result.rows.is_empty() => {
f.render_widget(
Paragraph::new("Table is empty")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
Some(result) => {
let widths: Vec<Constraint> = visible
.iter()
.map(|&i| Constraint::Length(screen.effective_col_width(i)))
.collect();
let header_cells: Vec<Cell> = visible
.iter()
.map(|&i| {
let name = if screen.collapsed_cols.contains(&i) {
"…".to_string()
} else {
truncate(&result.columns[i].name, screen.effective_col_width(i) as usize)
};
let style = if i == screen.selected_col {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
Cell::from(name).style(style)
})
.collect();
let header = RatRow::new(header_cells)
.style(Style::default().bg(Color::DarkGray))
.height(1);
let data_rows: Vec<RatRow> = result
.rows
.iter()
.map(|row| {
let cells: Vec<Cell> = visible
.iter()
.map(|&i| {
let val = row.values.get(i).unwrap_or(&Value::Null);
let s = value_display(val);
let display = if screen.collapsed_cols.contains(&i) {
s.chars().next()
.map(|c| format!("{c}…"))
.unwrap_or_else(|| "…".into())
} else {
truncate(&s, screen.effective_col_width(i) as usize)
};
Cell::from(display)
})
.collect();
RatRow::new(cells).height(1)
})
.collect();
let table = Table::new(data_rows, widths)
.header(header)
.block(Block::default().borders(Borders::ALL))
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(table, chunks[1], &mut screen.table_state);
}
}
let collapse_label = if screen.collapsed_cols.contains(&screen.selected_col) {
"Space: expand"
} else {
"Space: collapse"
};
f.render_widget(
Paragraph::new(format!(
" j/k: rows h/l: cols g/G: first/last PgUp/Dn: page {} q: back ",
collapse_label
))
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
}
fn value_display(v: &Value) -> String {
match v {
Value::Null => "NULL".into(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => format!("{f:.4}"),
Value::Text(s) => s.replace('\n', "↵").replace('\r', ""),
Value::Bytes(b) => format!("<{} bytes>", b.len()),
}
}
fn truncate(s: &str, max_chars: usize) -> String {
let max = max_chars.saturating_sub(1); if s.chars().count() <= max_chars {
s.to_string()
} else {
let t: String = s.chars().take(max).collect();
format!("{t}…")
}
}