use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row as TableRow, Table, Wrap,
},
};
use crate::config::ThemeName;
use crate::db::model::Value;
use crate::tui::layout;
use crate::tui::state::{
AppState, DataTab, EditTarget, EditorTab, FilterField, Overlay, Panel, RecordTab, SqlMode,
Visual,
};
#[derive(Clone, Copy)]
struct Palette {
foreground: Color,
muted: Color,
accent: Color,
surface: Color,
selection: Color,
green: Color,
orange: Color,
pink: Color,
cyan: Color,
}
fn palette(theme: ThemeName) -> Palette {
match theme {
ThemeName::Dracula => Palette {
foreground: Color::Rgb(248, 248, 242),
muted: Color::Rgb(98, 114, 164),
accent: Color::Rgb(189, 147, 249),
surface: Color::Rgb(40, 42, 54),
selection: Color::Rgb(68, 71, 90),
green: Color::Rgb(80, 250, 123),
orange: Color::Rgb(255, 184, 108),
pink: Color::Rgb(255, 121, 198),
cyan: Color::Rgb(139, 233, 253),
},
ThemeName::DraculaSoft => Palette {
foreground: Color::Rgb(220, 220, 215),
muted: Color::Rgb(104, 112, 142),
accent: Color::Rgb(154, 132, 190),
surface: Color::Rgb(43, 44, 56),
selection: Color::Rgb(61, 63, 78),
green: Color::Rgb(112, 180, 132),
orange: Color::Rgb(204, 157, 111),
pink: Color::Rgb(201, 125, 168),
cyan: Color::Rgb(116, 174, 188),
},
ThemeName::Monochrome => Palette {
foreground: Color::White,
muted: Color::DarkGray,
accent: Color::Gray,
surface: Color::Black,
selection: Color::DarkGray,
green: Color::Gray,
orange: Color::Gray,
pink: Color::White,
cyan: Color::Gray,
},
}
}
pub fn draw(frame: &mut Frame, state: &mut AppState) {
let l = layout::compute_with_panels(
frame.area(),
state.sidebar_visible,
state.inspector_visible,
state.config.editor_visible,
);
if state.sidebar_visible {
draw_tables_panel(frame, l.tables, state);
}
draw_grid_panel(frame, l.grid, state);
if state.config.editor_visible {
draw_editor_panel(frame, l.sql, state);
}
if state.inspector_visible {
draw_record_panel(frame, l.inspector, state);
}
draw_status_bar(frame, l.status, state);
draw_overlay(frame, state);
}
fn draw_record_panel(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = palette(state.config.theme);
let row_number = state.grid.page_offset + state.grid.selected_row + 1;
let suffix = if state.grid.rows.is_empty() {
String::new()
} else {
format!(" - row {row_number}")
};
let block = tabbed_panel_block(
4,
&[
("Record", state.record_tab == RecordTab::Fields),
("JSON", state.record_tab == RecordTab::Json),
],
suffix,
state.focus == Panel::Inspector,
state.config.theme,
);
let Some(row) = state.grid.rows.get(state.grid.selected_row) else {
frame.render_widget(Paragraph::new("Select a row to inspect").block(block), area);
return;
};
if state.record_tab == RecordTab::Json {
let object = state
.grid
.columns
.iter()
.zip(&row.values)
.map(|(column, value)| (column.name.clone(), inspector_json_value(value)))
.collect();
let value = serde_json::Value::Object(object);
let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
frame.render_widget(
Paragraph::new(text)
.style(Style::default().fg(colors.foreground))
.wrap(Wrap { trim: false })
.scroll((state.inspector_scroll, 0))
.block(block),
area,
);
return;
}
let mut lines = Vec::new();
let mut selected_line = 0;
for (index, (column, value)) in state.grid.columns.iter().zip(&row.values).enumerate() {
if index == state.grid.selected_col {
selected_line = lines.len();
}
let label_style = if index == state.grid.selected_col {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
lines.push(Line::from(vec![
Span::styled(column.name.clone(), label_style),
Span::styled(
format!(" {}", value.type_name()),
Style::default().fg(colors.muted),
),
]));
for value_line in value.detail_text().lines() {
lines.push(Line::from(Span::styled(
format!(" {value_line}"),
value_style(value, state.config.theme),
)));
}
lines.push(Line::from(""));
}
let visible_height = area.height.saturating_sub(2) as usize;
let selected_scroll = selected_line.saturating_sub(visible_height.saturating_sub(3)) as u16;
let scroll = state.inspector_scroll.max(selected_scroll);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.block(block),
area,
);
}
fn inspector_json_value(value: &Value) -> serde_json::Value {
match value {
Value::Bytes(bytes) => serde_json::json!({
"type": "bytes",
"length": bytes.len(),
"hex_preview": bytes
.iter()
.take(32)
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
"truncated": bytes.len() > 32,
}),
value => value.to_json(),
}
}
fn panel_block(title: String, focused: bool, theme: ThemeName) -> Block<'static> {
let colors = palette(theme);
let border_style = if focused {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
let title_style = if focused {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
};
Block::default()
.title(Span::styled(title, title_style))
.borders(Borders::ALL)
.border_style(border_style)
}
fn tabbed_panel_block(
number: usize,
tabs: &[(&str, bool)],
suffix: String,
focused: bool,
theme: ThemeName,
) -> Block<'static> {
let colors = palette(theme);
let mut spans = vec![Span::styled(
format!("[{number}] "),
Style::default().fg(colors.foreground),
)];
for (index, (label, active)) in tabs.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" | ", Style::default().fg(colors.muted)));
}
let style = if *active {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
spans.push(Span::styled((*label).to_string(), style));
}
spans.push(Span::styled(suffix, Style::default().fg(colors.foreground)));
let border_style = if focused {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
Block::default()
.title(Line::from(spans))
.borders(Borders::ALL)
.border_style(border_style)
}
fn draw_tables_panel(frame: &mut Frame, area: Rect, state: &mut AppState) {
let colors = palette(state.config.theme);
let items: Vec<ListItem> = state
.tables
.iter()
.map(|name| ListItem::new(name.as_str()))
.collect();
let highlight = if state.focus == Panel::Tables {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
};
let list = List::new(items)
.style(Style::default().fg(colors.foreground))
.highlight_style(highlight)
.block(panel_block(
format!("[1] Tables - {}", state.backend_name),
state.focus == Panel::Tables,
state.config.theme,
));
state
.tables_state
.select((!state.tables.is_empty()).then_some(state.selected_table));
frame.render_stateful_widget(list, area, &mut state.tables_state);
}
fn draw_grid_panel(frame: &mut Frame, area: Rect, state: &mut AppState) {
let colors = palette(state.config.theme);
let suffix = state
.loaded_table
.as_ref()
.map_or_else(String::new, |table| format!(" - {table}"));
let block = tabbed_panel_block(
3,
&[
("Data", state.data_tab == DataTab::Rows),
("Schema", state.data_tab == DataTab::Schema),
],
suffix,
state.focus == Panel::Grid,
state.config.theme,
);
if state.data_tab == DataTab::Schema {
let schema = state
.loaded_table
.as_ref()
.and_then(|table| state.schemas.get(table));
let Some(schema) = schema else {
frame.render_widget(
Paragraph::new("Load a table to inspect its schema")
.style(Style::default().fg(colors.muted))
.block(block),
area,
);
return;
};
let rows = schema.iter().map(|column| {
TableRow::new([
column.name.clone(),
column.col_type.clone(),
if column.pk { "primary key" } else { "" }.to_string(),
if column.notnull { "required" } else { "" }.to_string(),
])
});
let table = Table::new(
rows,
[
Constraint::Percentage(35),
Constraint::Percentage(25),
Constraint::Percentage(20),
Constraint::Percentage(20),
],
)
.header(
TableRow::new(["column", "type", "key", "nullability"])
.style(Style::default().fg(colors.accent).bg(colors.surface)),
)
.block(block);
frame.render_widget(table, area);
return;
}
if state.grid.columns.is_empty() {
let message = if state.loaded_table.is_none() && !state.sql.text.trim().is_empty() {
"Query returned no rows"
} else {
"Select a table and press Enter"
};
let paragraph = Paragraph::new(message)
.style(Style::default().fg(colors.muted))
.block(block);
frame.render_widget(paragraph, area);
return;
}
let col_width = state.config.col_width;
let visible_columns = (area.width.saturating_sub(2) / (col_width + 1)).max(1) as usize;
if state.grid.selected_col < state.grid.col_offset {
state.grid.col_offset = state.grid.selected_col;
} else if state.grid.selected_col >= state.grid.col_offset + visible_columns {
state.grid.col_offset = state.grid.selected_col + 1 - visible_columns;
}
let visible_columns = visible_columns.min(state.grid.columns.len() - state.grid.col_offset);
let column_range = state.grid.col_offset..state.grid.col_offset + visible_columns;
let header = TableRow::new(
state.grid.columns[column_range.clone()]
.iter()
.enumerate()
.map(|(visible_col, column)| {
let global_col = state.grid.col_offset + visible_col;
let Some(sort) = state
.grid
.sort
.as_ref()
.filter(|sort| sort.column == global_col)
else {
return Cell::from(column.name.clone());
};
Cell::from(format!("{} [{}]", column.name, sort.direction.indicator())).style(
Style::default()
.fg(colors.surface)
.bg(colors.accent)
.add_modifier(Modifier::BOLD),
)
}),
)
.style(
Style::default()
.fg(colors.accent)
.bg(colors.surface)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<TableRow> =
state
.grid
.rows
.iter()
.enumerate()
.map(|(row_index, row)| {
let cells = row.values[column_range.clone()].iter().enumerate().map(
|(visible_col, value)| {
let global_col = state.grid.col_offset + visible_col;
let style = if state.grid.visual == Visual::Column
&& global_col == state.grid.selected_col
{
value_style(value, state.config.theme).bg(colors.selection)
} else {
value_style(value, state.config.theme)
};
Cell::from(value.to_string()).style(style)
},
);
let row = TableRow::new(cells).style(if row_index % 2 == 1 {
Style::default().bg(colors.surface)
} else {
Style::default()
});
match state.grid.visual {
Visual::Rows { anchor }
if (anchor.min(state.grid.selected_row)
..=anchor.max(state.grid.selected_row))
.contains(&row_index) =>
{
row.style(Style::default().bg(colors.selection))
}
_ => row,
}
})
.collect();
let widths: Vec<Constraint> = column_range
.map(|_| Constraint::Length(col_width))
.collect();
let cell_highlight = if state.focus == Panel::Grid {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(colors.accent)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
};
let table = Table::new(rows, widths)
.header(header)
.cell_highlight_style(cell_highlight)
.block(block);
state.grid.table_state.select_cell(Some((
state.grid.selected_row,
state.grid.selected_col - state.grid.col_offset,
)));
frame.render_stateful_widget(table, area, &mut state.grid.table_state);
}
fn value_style(value: &Value, theme: ThemeName) -> Style {
let colors = palette(theme);
match value {
Value::Null => Style::default()
.fg(colors.muted)
.add_modifier(Modifier::ITALIC),
Value::Int(_) | Value::Float(_) | Value::Decimal(_) => Style::default().fg(colors.orange),
Value::Text(_) => Style::default().fg(colors.foreground),
Value::Bool(_) => Style::default().fg(colors.accent),
Value::Bytes(_) => Style::default().fg(colors.cyan),
Value::Json(_) => Style::default().fg(colors.green),
}
}
fn draw_editor_panel(frame: &mut Frame, area: Rect, state: &AppState) {
match state.editor_tab {
EditorTab::Filters => draw_filter_panel(frame, area, state),
EditorTab::Sql => draw_sql_panel(frame, area, state),
}
}
fn draw_filter_panel(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = palette(state.config.theme);
let focused = state.focus == Panel::Sql;
let access = if state.read_only {
"read-only"
} else {
"write enabled"
};
let block = tabbed_panel_block(
2,
&[
("Filters", state.editor_tab == EditorTab::Filters),
("SQL", state.editor_tab == EditorTab::Sql),
],
format!(" - {} - {access}", state.backend_name),
focused,
state.config.theme,
);
frame.render_widget(block, area);
if area.width < 8 || area.height < 4 {
return;
}
let inner = Rect::new(
area.x + 1,
area.y + 1,
area.width.saturating_sub(2),
area.height.saturating_sub(2),
);
let rows_area = layout::filter_rows_area(area);
let footer_area = Rect::new(inner.x, rows_area.bottom(), inner.width, 1);
if state.loaded_table.is_none() || state.grid.columns.is_empty() {
frame.render_widget(
Paragraph::new("Select a table to configure filters")
.style(Style::default().fg(colors.muted)),
rows_area,
);
} else {
let visible = rows_area.height.max(1) as usize;
let selected_row = state.filters.selected_row_index();
let start = selected_row
.saturating_sub(visible / 2)
.min(state.filters.rows().len().saturating_sub(visible));
let selected_field = state.filters.selected_field();
let rows = state
.filters
.rows()
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(row_index, filter)| {
let selected = focused && row_index == selected_row;
let column = filter.column_name.as_deref().unwrap_or("no column");
let value = if filter.operator.needs_value() {
if filter.value.is_empty()
&& !(selected && selected_field == FilterField::Value)
{
"EMPTY".to_string()
} else if selected && selected_field == FilterField::Value {
format!("{}█", filter.value)
} else {
filter.value.clone()
}
} else {
"-".to_string()
};
TableRow::new([
filter_control(
if filter.enabled { "[x]" } else { "[ ]" },
selected && selected_field == FilterField::Enabled,
filter.enabled,
colors,
),
filter_control(
format!("< {column} >"),
selected && selected_field == FilterField::Column,
filter.enabled,
colors,
),
filter_control(
format!("< {} >", filter.operator.label()),
selected && selected_field == FilterField::Operator,
filter.enabled,
colors,
),
filter_control(
value,
selected && selected_field == FilterField::Value,
filter.enabled,
colors,
),
filter_control(
"Apply",
selected && selected_field == FilterField::Apply,
filter.enabled,
colors,
),
filter_control(
"-",
selected && selected_field == FilterField::Remove,
filter.enabled,
colors,
),
filter_control(
"+",
selected && selected_field == FilterField::Add,
filter.enabled,
colors,
),
])
});
frame.render_widget(
Table::new(rows, layout::filter_control_constraints(rows_area.width)).column_spacing(1),
rows_area,
);
}
frame.render_widget(
Paragraph::new(format!(
"Tab/arrows: control Enter/A: apply +/-: row {} / {}: tab",
state
.config
.shortcuts
.binding(crate::config::ShortcutAction::PreviousTab),
state
.config
.shortcuts
.binding(crate::config::ShortcutAction::NextTab)
))
.style(Style::default().fg(colors.muted)),
footer_area,
);
}
fn filter_control(
content: impl Into<Cell<'static>>,
selected: bool,
enabled: bool,
colors: Palette,
) -> Cell<'static> {
let style = if selected {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else if enabled {
Style::default().fg(colors.foreground).bg(colors.surface)
} else {
Style::default().fg(colors.muted).bg(colors.surface)
};
content.into().style(style)
}
fn draw_sql_panel(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = palette(state.config.theme);
let focused = state.focus == Panel::Sql;
let text = if focused {
state.sql.text_with_cursor()
} else if state.sql.text.is_empty() {
"press 2, then i to write a query".to_string()
} else {
state.sql.text.clone()
};
let style = if focused || !state.sql.text.is_empty() {
Style::default()
} else {
Style::default().fg(colors.muted)
};
let mode_access = if state.read_only {
"read-only"
} else {
"write enabled"
};
let mode = match state.sql_mode {
SqlMode::Normal => "NORMAL",
SqlMode::Insert => "INSERT",
};
let number_width = text.lines().count().max(1).to_string().len();
let lines: Vec<Line> = text
.split('\n')
.enumerate()
.map(|(index, line)| {
let mut spans = vec![Span::styled(
format!("{:>number_width$} ", index + 1),
Style::default().fg(colors.muted),
)];
spans.extend(sql_spans(line, state.config.theme));
Line::from(spans)
})
.collect();
let cursor_line = state.sql.line_col().0 as u16;
let visible_height = area.height.saturating_sub(2).max(1);
let scroll = cursor_line.saturating_sub(visible_height - 1);
let paragraph = Paragraph::new(lines)
.style(style)
.scroll((scroll, 0))
.block(tabbed_panel_block(
2,
&[
("Filters", state.editor_tab == EditorTab::Filters),
("SQL", state.editor_tab == EditorTab::Sql),
],
format!(" - {} - {mode} - {mode_access}", state.backend_name),
focused,
state.config.theme,
));
frame.render_widget(paragraph, area);
if focused && state.sql_mode == SqlMode::Insert && state.overlay == Overlay::None {
draw_completion_menu(frame, area, state, number_width as u16, scroll);
}
}
fn draw_completion_menu(
frame: &mut Frame,
editor_area: Rect,
state: &AppState,
number_width: u16,
scroll: u16,
) {
let Some(menu) = &state.completion else {
return;
};
let colors = palette(state.config.theme);
let visible = 8usize;
let start = menu
.selected
.saturating_sub(visible / 2)
.min(menu.items.len().saturating_sub(visible));
let items = &menu.items[start..(start + visible).min(menu.items.len())];
let width = items
.iter()
.map(|item| item.label.chars().count() + item.detail.chars().count() + 5)
.max()
.unwrap_or(28)
.clamp(28, 48) as u16;
let height = items.len() as u16 + 2;
let (cursor_line, cursor_column) = state.sql.line_col();
let frame_area = frame.area();
let desired_x = editor_area.x + 2 + number_width + cursor_column as u16;
let x = desired_x.min(frame_area.right().saturating_sub(width));
let cursor_y = editor_area.y + 1 + (cursor_line as u16).saturating_sub(scroll);
let below = cursor_y.saturating_add(1);
let y = if below.saturating_add(height) <= frame_area.bottom() {
below
} else {
cursor_y.saturating_sub(height)
};
let area = Rect::new(x, y, width.min(frame_area.width), height);
let label_width = width.saturating_sub(14).max(12);
let rows = items.iter().enumerate().map(|(offset, item)| {
let index = start + offset;
let style = if index == menu.selected {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
};
TableRow::new([
Cell::from(item.label.clone()),
Cell::from(item.detail.clone()).style(Style::default().fg(colors.muted)),
])
.style(style)
});
let table = Table::new(rows, [Constraint::Length(label_width), Constraint::Min(10)]).block(
panel_block(
"suggestions Tab: accept".to_string(),
false,
state.config.theme,
),
);
frame.render_widget(Clear, area);
frame.render_widget(table, area);
}
fn sql_spans(line: &str, theme: ThemeName) -> Vec<Span<'static>> {
let colors = palette(theme);
let characters: Vec<char> = line.chars().collect();
let mut spans = Vec::new();
let mut index = 0;
while index < characters.len() {
if characters[index] == '-' && characters.get(index + 1) == Some(&'-') {
spans.push(Span::styled(
characters[index..].iter().collect::<String>(),
Style::default()
.fg(colors.muted)
.add_modifier(Modifier::ITALIC),
));
break;
}
if characters[index] == '\'' {
let start = index;
index += 1;
while index < characters.len() {
if characters[index] == '\'' {
index += 1;
if characters.get(index) == Some(&'\'') {
index += 1;
continue;
}
break;
}
index += 1;
}
spans.push(Span::styled(
characters[start..index].iter().collect::<String>(),
Style::default().fg(colors.green),
));
continue;
}
if characters[index].is_alphanumeric() || characters[index] == '_' {
let start = index;
index += 1;
while index < characters.len()
&& (characters[index].is_alphanumeric() || characters[index] == '_')
{
index += 1;
}
let token = characters[start..index].iter().collect::<String>();
let style = if token.chars().all(|character| character.is_ascii_digit()) {
Style::default().fg(colors.orange)
} else if is_sql_keyword(&token) {
Style::default()
.fg(colors.pink)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
};
spans.push(Span::styled(token, style));
continue;
}
let character = characters[index];
let style = if character == '█' {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else if character.is_whitespace() {
Style::default()
} else {
Style::default().fg(colors.muted)
};
spans.push(Span::styled(character.to_string(), style));
index += 1;
}
spans
}
fn is_sql_keyword(token: &str) -> bool {
matches!(
token.to_ascii_uppercase().as_str(),
"SELECT"
| "FROM"
| "WHERE"
| "JOIN"
| "LEFT"
| "RIGHT"
| "INNER"
| "OUTER"
| "ON"
| "GROUP"
| "ORDER"
| "BY"
| "LIMIT"
| "OFFSET"
| "INSERT"
| "INTO"
| "VALUES"
| "UPDATE"
| "SET"
| "DELETE"
| "CREATE"
| "TABLE"
| "DROP"
| "ALTER"
| "WITH"
| "AS"
| "AND"
| "OR"
| "NOT"
| "NULL"
| "IS"
| "IN"
| "LIKE"
| "HAVING"
| "DISTINCT"
| "RETURNING"
)
}
fn draw_status_bar(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = palette(state.config.theme);
let tab_keys = format!(
"{} / {}",
state
.config
.shortcuts
.binding(crate::config::ShortcutAction::PreviousTab),
state
.config
.shortcuts
.binding(crate::config::ShortcutAction::NextTab)
);
let hint = match (state.focus, state.sql_mode) {
(Panel::Tables, _) if state.backend_name == "DynamoDB" => {
"arrows: select | Enter: scan | b: tables | B: details".to_string()
}
(Panel::Tables, _) => "arrows: preview | Enter: data | b: tables | B: details".to_string(),
(Panel::Sql, _) if state.editor_tab == EditorTab::Filters => {
format!("Tab/arrow: control | Enter/A: apply | +/-: row | {tab_keys}: SQL")
}
(Panel::Sql, SqlMode::Normal) => {
format!("i: insert | Enter: run | Ctrl-r: read history | {tab_keys}: filters")
}
(Panel::Sql, SqlMode::Insert) => {
"Esc: normal | Enter: newline | Ctrl-Space/Tab: complete | Ctrl-r: history".to_string()
}
(Panel::Grid, _) if state.data_tab == DataTab::Rows => {
"arrows: move | o: sort current result/page | f: filter | y/Y: copy".to_string()
}
(Panel::Grid, _) => format!("{tab_keys}: tabs | s: table schema"),
(Panel::Inspector, _) => {
format!("j/k: scroll | {tab_keys}: fields/JSON | 3: data | B: close")
}
};
let operation = state.operation.as_ref().map(|operation| {
format!(
"{} {:.1}s (Esc: cancel)",
operation.label,
operation.started.elapsed().as_secs_f32()
)
});
let status = operation.as_deref().unwrap_or(&state.status);
let text = if status.is_empty() {
hint
} else {
format!("{status} | {hint}")
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(colors.foreground).bg(colors.surface),
)))
.style(Style::default().bg(colors.surface)),
area,
);
}
fn draw_overlay(frame: &mut Frame, state: &AppState) {
let colors = palette(state.config.theme);
match &state.overlay {
Overlay::None => {}
Overlay::Help => draw_help(frame, state),
Overlay::History(search) => {
let area = centered(frame.area(), 72, 14);
let block = panel_block("Query history".to_string(), true, state.config.theme);
let inner = block.inner(area);
let sections = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
let query = if search.query.is_empty() {
"Search: █".to_string()
} else {
format!("Search: {}█", search.query)
};
let items: Vec<ListItem> = if search.matches.is_empty() {
vec![ListItem::new("No matching queries").style(Style::default().fg(colors.muted))]
} else {
let visible = usize::from(sections[1].height).max(1);
let start = search
.selected
.saturating_sub(visible / 2)
.min(search.matches.len().saturating_sub(visible));
search
.matches
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(index, statement)| {
let style = if index == search.selected {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
};
ListItem::new(history_preview(statement)).style(style)
})
.collect()
};
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(query).style(Style::default().fg(colors.accent)),
sections[0],
);
frame.render_widget(List::new(items), sections[1]);
frame.render_widget(
Paragraph::new("Up/Down: select Enter: load Esc: close")
.style(Style::default().fg(colors.muted)),
sections[2],
);
}
Overlay::Schema { table, columns } => {
let rows: Vec<TableRow> = columns
.iter()
.map(|c| {
TableRow::new(vec![
c.name.clone(),
c.col_type.clone(),
if c.notnull {
"NOT NULL".to_string()
} else {
String::new()
},
if c.pk {
"PK".to_string()
} else {
String::new()
},
])
})
.collect();
let height = columns.len() as u16 + 3;
let area = centered(frame.area(), 44, height);
let table = Table::new(
rows,
[
Constraint::Length(16),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(2),
],
)
.header(
TableRow::new(vec!["column", "type", "", ""])
.style(Style::default().add_modifier(Modifier::BOLD)),
)
.block(panel_block(
format!("Schema: {table}"),
true,
state.config.theme,
));
frame.render_widget(Clear, area);
frame.render_widget(table, area);
}
Overlay::Edit { text, target, cell } => {
let target = edit_target_label(state, *target, cell.as_ref());
let area = centered(frame.area(), 58, 3);
let paragraph = Paragraph::new(format!("{text}█")).block(panel_block(
format!("Edit {target}"),
true,
state.config.theme,
));
frame.render_widget(Clear, area);
frame.render_widget(paragraph, area);
}
Overlay::ConfirmSql { sql } => {
let area = centered(frame.area(), 64, 7);
let paragraph = Paragraph::new(vec![
Line::from("This statement may modify the database:"),
Line::from(""),
Line::from(sql.as_str()),
Line::from(""),
Line::from("Enter: execute Esc: cancel"),
])
.block(panel_block(
"Confirm write".to_string(),
true,
state.config.theme,
));
frame.render_widget(Clear, area);
frame.render_widget(paragraph, area);
}
Overlay::Settings { selected } => {
let rows = [
("row_limit", state.config.row_limit.to_string()),
("col_width", state.config.col_width.to_string()),
(
"foreground_timeout_ms",
state.config.foreground_timeout_ms.to_string(),
),
("theme", state.config.theme.label().to_string()),
(
"pane_2",
if state.config.editor_visible {
"visible"
} else {
"hidden"
}
.to_string(),
),
(
"query_history",
if state.history_recording_enabled() {
"enabled"
} else {
"disabled"
}
.to_string(),
),
]
.into_iter()
.enumerate()
.map(|(index, (name, value))| {
let style = if index == *selected {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
};
TableRow::new(vec![name.to_string(), value]).style(style)
});
let area = centered(frame.area(), 48, 9);
let table = Table::new(rows, [Constraint::Length(20), Constraint::Length(16)])
.header(
TableRow::new(["setting", "value"])
.style(Style::default().add_modifier(Modifier::BOLD)),
)
.block(panel_block(
"Settings - Enter to change".to_string(),
true,
state.config.theme,
));
frame.render_widget(Clear, area);
frame.render_widget(table, area);
}
Overlay::Value { title, text } => {
let area = centered(frame.area(), 72, 14);
let paragraph = Paragraph::new(text.as_str())
.wrap(Wrap { trim: false })
.block(panel_block(title.clone(), true, state.config.theme));
frame.render_widget(Clear, area);
frame.render_widget(paragraph, area);
}
Overlay::Copy { format, headers } => {
let area = centered(frame.area(), 58, 9);
let options = [
crate::tui::state::CopyFormat::Csv,
crate::tui::state::CopyFormat::Tsv,
crate::tui::state::CopyFormat::Json,
];
let mut format_line =
vec![Span::styled("Format ", Style::default().fg(colors.muted))];
for option in options {
let style = if option == *format {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
format_line.push(Span::styled(format!(" {} ", option.label()), style));
format_line.push(Span::raw(" "));
}
let lines = vec![
Line::from(format_line),
Line::from(""),
Line::from(vec![
Span::styled("Headers ", Style::default().fg(colors.muted)),
Span::styled(
if *headers { "[x] include" } else { "[ ] omit" },
Style::default().fg(if *headers { colors.green } else { colors.muted }),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Scope ", Style::default().fg(colors.muted)),
Span::styled(
state.copy_scope_label(),
Style::default().fg(colors.foreground),
),
]),
Line::from(""),
Line::from("h/l: format Space: headers Enter: copy Esc: cancel"),
];
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines).block(panel_block(
"Advanced copy".to_string(),
true,
state.config.theme,
)),
area,
);
}
}
}
fn edit_target_label(
state: &AppState,
target: EditTarget,
cell: Option<&crate::tui::state::CellEditIdentity>,
) -> String {
match (target, cell) {
(EditTarget::Cell, Some(identity)) => {
let current_identity_matches = state.loaded_table.as_deref()
== Some(identity.table.as_str())
&& state.selected_rowid() == Some(identity.rowid)
&& state.selected_column_name() == Some(identity.column.as_str());
let changed = if current_identity_matches {
""
} else {
" (view changed)"
};
format!(
"{}.{} rowid={}{}",
identity.table, identity.column, identity.rowid, changed
)
}
(EditTarget::Cell, None) => "cell (identity unavailable)".to_string(),
(EditTarget::RowLimit, _) => "row_limit".to_string(),
(EditTarget::ColWidth, _) => "col_width".to_string(),
(EditTarget::ForegroundTimeout, _) => "foreground_timeout_ms".to_string(),
}
}
fn draw_help(frame: &mut Frame, state: &AppState) {
use crate::config::ShortcutAction;
let keys = &state.config.shortcuts;
let lines = vec![
"1/2/3/4 Tables / Filters+SQL / Data / Record".to_string(),
format!(
"{}/{} previous/next tab in pane",
keys.binding(ShortcutAction::PreviousTab),
keys.binding(ShortcutAction::NextTab)
),
format!(
"{}/{} next/previous panel",
keys.binding(ShortcutAction::NextPanel),
keys.binding(ShortcutAction::PreviousPanel)
),
"i/Esc query insert/normal mode".to_string(),
"Ctrl-Space open SQL completions".to_string(),
"Ctrl-r search successful read-query history".to_string(),
"Enter run SQL in normal mode".to_string(),
"Filter tab Tab/arrows select controls".to_string(),
"Filter tab Enter/A applies all rows".to_string(),
"Filter tab +/- adds/removes a row".to_string(),
"j/k ↓/↑ move down/up".to_string(),
"h/l ←/→ move across columns".to_string(),
"V/v select rows/column".to_string(),
format!(
"y/{} yank / advanced copy",
keys.binding(ShortcutAction::AdvancedCopy)
),
"g/G jump to top/bottom of page".to_string(),
"Data Rows o sort current result/page asc/desc/off".to_string(),
" o overrides configured shortcuts there".to_string(),
"PgUp/PgDn previous/next page".to_string(),
"r refresh tables and data".to_string(),
"Enter load table / confirm edit".to_string(),
"s table schema".to_string(),
format!(
"{} focus filters",
keys.binding(ShortcutAction::Filter)
),
"e edit cell (write mode)".to_string(),
format!(
"{} toggle pane 2",
keys.binding(ShortcutAction::ToggleEditor)
),
format!(
"{} settings",
keys.binding(ShortcutAction::Settings)
),
format!(
"{} toggle table explorer",
keys.binding(ShortcutAction::ToggleTables)
),
format!(
"{} toggle record details",
keys.binding(ShortcutAction::ToggleRecord)
),
"J/K scroll record details".to_string(),
"double-click open table/cell".to_string(),
"Esc close popup / leave SQL".to_string(),
format!(
"{} toggle this help",
keys.binding(ShortcutAction::Help)
),
format!("{} quit", keys.binding(ShortcutAction::Quit)),
];
let area = centered(frame.area(), 58, lines.len() as u16 + 2);
let paragraph = Paragraph::new(lines.into_iter().map(Line::from).collect::<Vec<_>>())
.block(panel_block("Help".to_string(), true, state.config.theme));
frame.render_widget(Clear, area);
frame.render_widget(paragraph, area);
}
fn history_preview(statement: &str) -> String {
statement.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use crate::db::model::{Column, Row, SchemaColumn, TablePage, Value};
use crate::tui::state::{AppEvent, CopyFormat, Panel, dispatch};
use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
fn buffer_to_string(buffer: &Buffer) -> String {
let area = buffer.area;
let mut s = String::new();
for y in 0..area.height {
for x in 0..area.width {
s.push_str(buffer[(x, y)].symbol());
}
s.push('\n');
}
s
}
fn render(state: &mut AppState) -> String {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| draw(frame, state)).unwrap();
buffer_to_string(terminal.backend().buffer())
}
fn grid_page() -> TablePage {
TablePage {
columns: vec![
Column { name: "id".into() },
Column {
name: "name".into(),
},
],
rows: vec![
Row {
values: vec![Value::Int(1), Value::Text("Lem".into())],
},
Row {
values: vec![Value::Int(2), Value::Text("Calvino".into())],
},
],
rowids: Some(vec![1, 2]),
offset: 0,
has_more: false,
}
}
#[test]
fn renders_table_names_in_left_panel() {
let mut state = AppState::new(vec!["authors".to_string(), "books".to_string()]);
let content = render(&mut state);
assert!(content.contains("authors"));
assert!(content.contains("books"));
}
#[test]
fn renders_grid_placeholder_when_no_table_loaded() {
let mut state = AppState::new(vec!["authors".to_string()]);
let content = render(&mut state);
assert!(content.contains("Select a table"));
}
#[test]
fn renders_status_bar_keybinding_hints() {
let mut state = AppState::new(vec![]);
let content = render(&mut state);
assert!(content.contains("preview"));
assert!(content.contains("details"));
}
#[test]
fn renders_numbered_panel_titles() {
let mut state = AppState::new(vec![]);
let content = render(&mut state);
assert!(content.contains("[1] Tables"));
assert!(content.contains("[2] Filters | SQL"));
assert!(content.contains("[3] Data"));
assert!(content.contains("[4] Record"));
}
#[test]
fn renders_loaded_table_name_in_grid_title() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
let content = render(&mut state);
assert!(content.contains("[3] Data | Schema - authors"));
}
#[test]
fn record_inspector_shows_all_values_and_types() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
let content = render(&mut state);
assert!(content.contains("[4] Record"));
assert!(content.contains("integer"));
assert!(content.contains("Lem"));
}
#[test]
fn data_and_record_tabs_render_schema_and_json() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
state.cache_schema(
"authors".to_string(),
vec![SchemaColumn {
name: "id".to_string(),
col_type: "INTEGER".to_string(),
notnull: true,
pk: true,
}],
);
state.data_tab = DataTab::Schema;
state.record_tab = RecordTab::Json;
let content = render(&mut state);
assert!(content.contains("INTEGER"));
assert!(content.contains("\"name\""));
assert!(content.contains("\"Lem\""));
}
#[test]
fn data_and_record_panes_have_independent_focus_styles() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
state.focus = Panel::Grid;
let backend = TestBackend::new(100, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| draw(frame, &mut state)).unwrap();
let layout = layout::compute_with_panels(Rect::new(0, 0, 100, 30), true, true, true);
let colors = palette(state.config.theme);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(layout.grid.x, layout.grid.y)].fg, colors.accent);
assert_eq!(
buffer[(layout.inspector.x, layout.inspector.y)].fg,
colors.muted
);
}
#[test]
fn renders_sql_buffer_in_sql_panel() {
let mut state = AppState::new(vec![]);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Sql));
state.editor_tab = EditorTab::Sql;
state.sql_mode = SqlMode::Insert;
for c in "select 1".chars() {
dispatch(&mut state, AppEvent::InputChar(c));
}
let content = render(&mut state);
assert!(content.contains("select 1"));
assert!(content.contains("INSERT"));
}
#[test]
fn renders_live_completion_beside_the_sql_cursor() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Sql));
state.editor_tab = EditorTab::Sql;
state.sql_mode = SqlMode::Insert;
state.sql.insert_str("sel");
state.refresh_completion(false);
let content = render(&mut state);
assert!(content.contains("suggestions"));
assert!(content.contains("SELECT"));
assert!(content.contains("keyword"));
}
#[test]
fn highlights_selected_cell_when_grid_focused() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Grid));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| draw(frame, &mut state)).unwrap();
let colors = palette(state.config.theme);
let highlighted = terminal
.backend()
.buffer()
.content()
.iter()
.any(|cell| cell.bg == colors.selection && cell.fg == colors.foreground);
assert!(highlighted);
}
#[test]
fn renders_sort_direction_and_honest_current_result_scope() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Grid));
dispatch(&mut state, AppEvent::CycleSort);
let ascending = render(&mut state);
assert!(ascending.contains("id [ASC]"));
assert!(ascending.contains("sorted current result/page"));
dispatch(&mut state, AppEvent::CycleSort);
let descending = render(&mut state);
assert!(descending.contains("id [DESC]"));
state.data_tab = DataTab::Schema;
state.status.clear();
let schema = render(&mut state);
assert!(!schema.contains("o: sort"));
}
#[test]
fn renders_accent_and_typed_value_colors() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
let backend = TestBackend::new(100, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| draw(frame, &mut state)).unwrap();
let cells = terminal.backend().buffer().content();
let colors = palette(state.config.theme);
assert!(cells.iter().any(|cell| cell.fg == colors.accent));
assert!(cells.iter().any(|cell| cell.fg == colors.orange));
assert!(cells.iter().any(|cell| cell.bg == colors.surface));
}
#[test]
fn colors_sql_keywords_strings_and_comments() {
let theme = ThemeName::DraculaSoft;
let colors = palette(theme);
let spans = sql_spans("SELECT 'Lem' -- author", theme);
assert!(
spans
.iter()
.any(|span| span.content == "SELECT" && span.style.fg == Some(colors.pink))
);
assert!(
spans
.iter()
.any(|span| span.content == "'Lem'" && span.style.fg == Some(colors.green))
);
assert!(
spans
.iter()
.any(|span| span.content == "-- author" && span.style.fg == Some(colors.muted))
);
}
#[test]
fn renders_help_overlay() {
let mut state = AppState::new(vec![]);
dispatch(&mut state, AppEvent::ToggleHelp);
let content = render(&mut state);
assert!(content.contains("advanced copy"));
assert!(content.contains("Tables / Filters+SQL / Data"));
assert!(content.contains("Ctrl-Space"));
assert!(content.contains("sort current result/page asc/desc/off"));
assert!(content.contains("o overrides configured shortcuts there"));
assert!(content.contains("Ctrl-r"));
assert!(content.contains("successful read-query history"));
}
#[test]
fn renders_searchable_history_overlay_and_selected_match() {
let mut state = AppState::new(vec![]);
state.editor_tab = EditorTab::Sql;
state
.query_history
.record("SELECT name FROM authors ORDER BY name");
state
.query_history
.record("SELECT title FROM books ORDER BY title");
state.open_history_search();
state.insert_history_search("sfbk");
let content = render(&mut state);
assert!(content.contains("Query history"));
assert!(content.contains("Search: sfbk"));
assert!(content.contains("SELECT title FROM books ORDER BY title"));
assert!(!content.contains("SELECT name FROM authors ORDER BY name"));
assert!(content.contains("Enter: load"));
}
#[test]
fn history_overlay_scrolls_to_keep_the_last_selection_visible() {
let mut state = AppState::new(vec![]);
state.editor_tab = EditorTab::Sql;
for index in 0..100 {
state
.query_history
.record(&format!("SELECT {index} AS history_row_{index:03}"));
}
state.open_history_search();
let Overlay::History(search) = &mut state.overlay else {
panic!("expected history overlay");
};
search.selected = 99;
let content = render(&mut state);
assert!(content.contains("history_row_000"));
assert!(!content.contains("history_row_099"));
}
#[test]
fn settings_show_the_query_history_recording_toggle() {
let mut state = AppState::new(vec![]);
state.overlay = Overlay::Settings { selected: 4 };
let enabled = render(&mut state);
assert!(enabled.contains("query_history"));
assert!(enabled.contains("enabled"));
state.config.query_history_enabled = false;
let disabled = render(&mut state);
assert!(disabled.contains("disabled"));
}
#[test]
fn filters_are_the_default_pane_two_tab() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Sql));
let content = render(&mut state);
assert!(content.contains("[2] Filters | SQL"));
assert!(content.contains("< id >"));
assert!(content.contains("equals"));
assert!(content.contains("EMPTY"));
assert!(content.contains("Apply"));
}
#[test]
fn renders_advanced_copy_overlay() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
state.overlay = Overlay::Copy {
format: CopyFormat::Tsv,
headers: true,
};
let content = render(&mut state);
assert!(content.contains("Advanced copy"));
assert!(content.contains("TSV"));
assert!(content.contains("[x] include"));
assert!(content.contains("current row 1"));
}
#[test]
fn renders_schema_overlay() {
let mut state = AppState::new(vec![]);
dispatch(
&mut state,
AppEvent::SchemaLoaded {
table: "books".into(),
columns: vec![SchemaColumn {
name: "title".into(),
col_type: "TEXT".into(),
notnull: true,
pk: false,
}],
},
);
let content = render(&mut state);
assert!(content.contains("Schema: books"));
assert!(content.contains("title"));
assert!(content.contains("TEXT"));
assert!(content.contains("NOT NULL"));
}
#[test]
fn renders_edit_overlay_with_cell_text() {
let mut state = AppState::new(vec!["authors".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Grid));
dispatch(&mut state, AppEvent::MoveRight);
dispatch(&mut state, AppEvent::EditStart);
let content = render(&mut state);
assert!(content.contains("Edit authors.name rowid=1"));
assert!(content.contains("Lem"));
}
#[test]
fn edit_overlay_keeps_captured_target_when_view_changes() {
let mut state = AppState::new(vec!["authors".to_string(), "books".to_string()]);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("authors".into()),
page: grid_page(),
},
);
dispatch(&mut state, AppEvent::FocusPanel(Panel::Grid));
dispatch(&mut state, AppEvent::MoveRight);
dispatch(&mut state, AppEvent::EditStart);
dispatch(
&mut state,
AppEvent::RowsLoaded {
table: Some("books".into()),
page: TablePage {
columns: vec![Column {
name: "title".into(),
}],
rows: vec![Row {
values: vec![Value::Text("Other".into())],
}],
rowids: Some(vec![99]),
offset: 0,
has_more: false,
},
},
);
let content = render(&mut state);
assert!(content.contains("Edit authors.name rowid=1"));
assert!(content.contains("view changed"));
}
}