use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Layout}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState}, }; use crate::{ Result, db::{Column, Database, PAGE_SIZE, Page, Relation}, error_text, }; pub struct App { db: Database, relations: Vec, selection: ListState, active: Option, columns: Vec, page: Page, page_index: usize, rows_state: TableState, schema_state: TableState, column_offset: usize, sidebar_focus: bool, show_schema: bool, error: Option, } impl App { pub fn new(mut db: Database) -> Result { let relations = db.relations()?; let selected = (!relations.is_empty()).then_some(0); let mut app = Self { db, relations, selection: ListState::default().with_selected(selected), active: None, columns: Vec::new(), page: Page { rows: Vec::new(), has_next: false, }, page_index: 0, rows_state: TableState::default(), schema_state: TableState::default(), column_offset: 0, sidebar_focus: true, show_schema: false, error: None, }; app.open_selection(); Ok(app) } pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool { if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) { return true; } match key.code { KeyCode::Tab | KeyCode::BackTab => self.sidebar_focus = !self.sidebar_focus, KeyCode::Enter if self.sidebar_focus => { self.open_selection(); self.sidebar_focus = false; } KeyCode::Esc => self.sidebar_focus = true, KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX), KeyCode::Char('s') => { self.show_schema = !self.show_schema; self.column_offset = 0; } KeyCode::Left | KeyCode::Char('h') if !self.sidebar_focus => { self.column_offset = self.column_offset.saturating_sub(1); } KeyCode::Right | KeyCode::Char('l') if !self.sidebar_focus => { let count = if self.show_schema { 5 } else { self.columns.len() }; if self.column_offset + 1 < count { self.column_offset += 1; } } KeyCode::Char('n') | KeyCode::PageDown if !self.show_schema && self.page.has_next => { self.load_page(self.page_index + 1); } KeyCode::Char('p') | KeyCode::PageUp if !self.show_schema && self.page_index > 0 => { self.load_page(self.page_index - 1); } KeyCode::Char('r') => self.refresh(), _ => {} } false } fn move_selection(&mut self, delta: isize) { if self.sidebar_focus { let next = moved_index(self.selection.selected(), self.relations.len(), delta); self.selection.select(next); } else if self.show_schema { let next = moved_index(self.schema_state.selected(), self.columns.len(), delta); self.schema_state.select(next); } else { let next = moved_index(self.rows_state.selected(), self.page.rows.len(), delta); self.rows_state.select(next); } } fn open_selection(&mut self) { self.active = self.selection.selected(); self.columns.clear(); self.page = Page { rows: Vec::new(), has_next: false, }; self.page_index = 0; self.column_offset = 0; self.rows_state = TableState::default(); self.schema_state = TableState::default(); self.error = None; if let Some(index) = self.active { match self.db.columns(&self.relations[index]) { Ok(columns) => { self.columns = columns; self.schema_state .select((!self.columns.is_empty()).then_some(0)); self.load_page(0); } Err(error) => self.error = Some(error_text(error.as_ref())), } } } fn load_page(&mut self, index: usize) { if let Some(active) = self.active { // ponytail: synchronous reads block input; use a worker if responsiveness matters. match self.db.page(&self.relations[active], &self.columns, index) { Ok(page) => { self.rows_state = TableState::default().with_selected((!page.rows.is_empty()).then_some(0)); self.page = page; self.page_index = index; self.error = None; } Err(error) => self.error = Some(error_text(error.as_ref())), } } } fn refresh(&mut self) { match self.db.relations() { Ok(relations) => { let id = self.active.map(|index| self.relations[index].id); let selected = relations .iter() .position(|relation| Some(relation.id) == id) .or_else(|| (!relations.is_empty()).then_some(0)); self.relations = relations; self.selection = ListState::default().with_selected(selected); self.open_selection(); } Err(error) => self.error = Some(error_text(error.as_ref())), } } pub(crate) fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); if area.width < 70 || area.height < 12 { frame.render_widget( Paragraph::new("Enlarge terminal to at least 70×12. q to quit."), area, ); return; } let [header, body, status, help] = Layout::vertical([ Constraint::Length(1), Constraint::Min(4), Constraint::Length(2), Constraint::Length(2), ]) .areas(area); frame.render_widget( Paragraph::new(Line::from_iter([ " dbdash │ ", self.db.name(), " │ READ ONLY", ])) .style(Style::default().fg(Color::Cyan)), header, ); let [sidebar, content] = Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body); let sidebar_title = format!(" Relations ({}) ", self.relations.len()); let sidebar_block = panel(&sidebar_title, self.sidebar_focus); if self.relations.is_empty() { frame.render_widget( Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block), sidebar, ); } else { let list = List::new( self.relations .iter() .map(|relation| ListItem::new(relation.label.as_str())), ) .block(sidebar_block) .highlight_style(selected_style()) .highlight_symbol("› "); frame.render_stateful_widget(list, sidebar, &mut self.selection); } let title = if self.show_schema { " Schema · s for rows " } else { " Rows · s for schema " }; let content_block = panel(title, !self.sidebar_focus); let count = if self.show_schema { 5 } else { self.columns.len() }; let visible = usize::from(content.width.saturating_sub(4) / 21) .max(1) .min(count.saturating_sub(self.column_offset)); let widths = (0..visible).map(|index| { if index + 1 == visible { Constraint::Min(1) } else { Constraint::Length(20) } }); if self.active.is_none() { frame.render_widget( Paragraph::new("Choose a relation and press Enter.").block(content_block), content, ); } else if self.show_schema { let headers = ["Column", "Type", "Nullable", "Key", "Default"]; let rows = self.columns.iter().map(|column| { Row::new( [ column.label.as_str(), column.data_type.as_str(), if column.nullable { "yes" } else { "no" }, if column.primary_key { "PK" } else { "" }, column.default.as_str(), ] .into_iter() .skip(self.column_offset) .take(visible), ) }); let table = Table::new(rows, widths) .header( Row::new(headers.into_iter().skip(self.column_offset).take(visible)) .style(header_style()), ) .block(content_block) .row_highlight_style(selected_style()) .highlight_symbol("› "); frame.render_stateful_widget(table, content, &mut self.schema_state); } else if self.page.rows.is_empty() { frame.render_widget( Paragraph::new(if self.error.is_some() { "Could not load rows. See error below." } else { "No rows." }) .block(content_block), content, ); } else { let headers = self .columns .iter() .skip(self.column_offset) .take(visible) .map(|column| column.label.as_str()); let rows = self.page.rows.iter().map(|row| { Row::new( row.iter() .skip(self.column_offset) .take(visible) .map(|value| match value { Some(value) => Cell::from(value.as_str()), None => Cell::from("NULL").style( Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), ), }), ) }); let table = Table::new(rows, widths) .header(Row::new(headers).style(header_style())) .block(content_block) .row_highlight_style(selected_style()) .highlight_symbol("› "); frame.render_stateful_widget(table, content, &mut self.rows_state); } let description = if let Some(index) = self.active { let relation = &self.relations[index]; if self.show_schema { format!( " {} [{}] · {} columns · fields {}–{}/5", relation.label, relation.kind, self.columns.len(), self.column_offset + 1, self.column_offset + visible ) } else { let start = if self.page.rows.is_empty() { 0 } else { self.page_index * PAGE_SIZE + 1 }; let end = self.page_index * PAGE_SIZE + self.page.rows.len(); format!( " {} [{}] · rows {start}–{end} · page {}{} · columns {}–{}/{}", relation.label, relation.kind, self.page_index + 1, if self.page.has_next { " (more)" } else { "" }, if count > 0 { self.column_offset + 1 } else { 0 }, self.column_offset + visible, count ) } } else { " No readable relations. r to refresh.".into() }; let [description_area, error_area] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(status); frame.render_widget(Paragraph::new(description), description_area); if let Some(error) = &self.error { frame.render_widget( Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), error_area, ); } frame.render_widget(Paragraph::new( " Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c connect · q quit" ).style(Style::default().fg(Color::DarkGray)), help); } } fn panel(title: &str, focused: bool) -> Block<'_> { Block::bordered() .title(title) .border_style(Style::default().fg(if focused { Color::Cyan } else { Color::DarkGray })) } fn selected_style() -> Style { Style::default().bg(Color::Blue).fg(Color::White) } fn header_style() -> Style { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } fn moved_index(selected: Option, count: usize, delta: isize) -> Option { if count == 0 { return None; } Some( selected .unwrap_or(0) .saturating_add_signed(delta) .min(count - 1), ) } #[cfg(test)] mod tests { use super::moved_index; #[test] fn navigation_clamps_to_available_items() { assert_eq!(moved_index(None, 0, 1), None); assert_eq!(moved_index(None, 3, 1), Some(1)); assert_eq!(moved_index(Some(0), 3, -1), Some(0)); assert_eq!(moved_index(Some(2), 3, 1), Some(2)); assert_eq!(moved_index(Some(1), 3, isize::MIN), Some(0)); assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2)); } }