1pub mod detail;
4pub mod keymap;
5pub mod search;
6pub mod table;
7
8use std::io::{self, IsTerminal, Stdout};
9use std::time::{Duration, Instant};
10
11use crossterm::event::{self, Event, KeyEvent};
12use crossterm::execute;
13use crossterm::terminal::{
14 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Style};
19use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
20use ratatui::Terminal;
21
22use crate::error::{CliError, Result};
23use crate::models::{Column, Row};
24
25use self::detail::DetailPanel;
26use self::keymap::{action_for_key, help_items, Action};
27use self::search::SearchState;
28use self::table::TableView;
29
30pub struct TuiApp {
32 table: TableView,
33 search: SearchState,
34 detail: DetailPanel,
35 show_help: bool,
36 connection_label: String,
37 row_count: usize,
38 processing: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum EventResult {
44 Continue,
45 Exit,
46}
47
48impl TuiApp {
49 pub fn new(
50 columns: Vec<Column>,
51 rows: Vec<Row>,
52 connection_label: impl Into<String>,
53 processing: bool,
54 ) -> Self {
55 let row_count = rows.len();
56 let table = TableView::new(columns, rows);
57 let search = SearchState::default();
58 let detail = DetailPanel::default();
59 Self {
60 table,
61 search,
62 detail,
63 show_help: false,
64 connection_label: connection_label.into(),
65 row_count,
66 processing,
67 }
68 }
69
70 pub fn run(mut self) -> Result<()> {
71 if !is_tty() {
72 return Err(CliError::InvalidArgument(
73 "TUI requires a TTY. Run without --tui in batch mode.".to_string(),
74 ));
75 }
76 enable_raw_mode()?;
77 let mut stdout = io::stdout();
78 execute!(stdout, EnterAlternateScreen)?;
79
80 let backend = CrosstermBackend::new(stdout);
81 let mut terminal = Terminal::new(backend)?;
82 terminal.clear()?;
83
84 let tick_rate = Duration::from_millis(16);
85 let mut last_tick = Instant::now();
86
87 loop {
88 terminal.draw(|frame| self.draw(frame))?;
89
90 let timeout = tick_rate
91 .checked_sub(last_tick.elapsed())
92 .unwrap_or_else(|| Duration::from_secs(0));
93
94 if event::poll(timeout)? {
95 if let Event::Key(key) = event::read()? {
96 match self.handle_key(key)? {
97 EventResult::Exit => break,
98 EventResult::Continue => {}
99 }
100 }
101 }
102
103 if last_tick.elapsed() >= tick_rate {
104 last_tick = Instant::now();
105 }
106 }
107
108 cleanup_terminal(terminal)
109 }
110
111 pub fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
112 let area = frame.size();
113 let (table_area, detail_area, status_area) = split_layout(area, self.detail.is_visible());
114
115 self.table.render(frame, table_area, &self.search);
116
117 if self.detail.is_visible() {
118 if let Some(selected) = self.table.selected_row() {
119 self.detail
120 .render(frame, detail_area, self.table.columns(), selected);
121 } else {
122 self.detail.render_empty(frame, detail_area);
123 }
124 }
125
126 render_status(
127 frame,
128 status_area,
129 &self.search,
130 self.show_help,
131 &self.connection_label,
132 self.row_count,
133 self.processing,
134 );
135
136 if self.show_help {
137 render_help(frame, area);
138 }
139 }
140
141 pub fn handle_key(&mut self, key: KeyEvent) -> Result<EventResult> {
142 if let Some(action) = action_for_key(key, self.search.is_active()) {
143 return self.handle_action(action);
144 }
145 Ok(EventResult::Continue)
146 }
147
148 fn handle_action(&mut self, action: Action) -> Result<EventResult> {
149 match action {
150 Action::Quit => return Ok(EventResult::Exit),
151 Action::ToggleHelp => {
152 self.show_help = !self.show_help;
153 }
154 Action::MoveUp => self.table.move_up(),
155 Action::MoveDown => self.table.move_down(),
156 Action::MoveLeft => self.table.move_left(),
157 Action::MoveRight => self.table.move_right(),
158 Action::PageUp => self.table.page_up(),
159 Action::PageDown => self.table.page_down(),
160 Action::JumpTop => self.table.jump_top(),
161 Action::JumpBottom => self.table.jump_bottom(),
162 Action::ToggleDetail => self.detail.toggle(),
163 Action::SearchMode => self.search.activate(),
164 Action::SearchNext => {
165 let next = self.search.next_match(&self.table)?;
166 self.select_match(next);
167 }
168 Action::SearchPrev => {
169 let prev = self.search.prev_match(&self.table)?;
170 self.select_match(prev);
171 }
172 Action::InputChar(ch) => {
173 self.search.push_char(ch, &self.table)?;
174 self.select_match(self.search.current_match());
175 }
176 Action::Backspace => {
177 self.search.backspace(&self.table)?;
178 self.select_match(self.search.current_match());
179 }
180 Action::ConfirmSearch => {
181 self.search.deactivate();
182 self.select_match(self.search.current_match());
183 }
184 Action::CancelSearch => self.search.cancel(),
185 Action::DetailUp => self.detail.scroll_up(),
186 Action::DetailDown => self.detail.scroll_down(),
187 }
188 Ok(EventResult::Continue)
189 }
190
191 fn select_match(&mut self, row: Option<usize>) {
192 if let Some(row) = row {
193 self.table.select_row(row);
194 }
195 }
196
197 #[allow(dead_code)]
198 pub fn selected_index(&self) -> Option<usize> {
199 self.table.selected_index()
200 }
201
202 #[allow(dead_code)]
203 pub fn is_detail_visible(&self) -> bool {
204 self.detail.is_visible()
205 }
206
207 #[allow(dead_code)]
208 pub fn is_help_visible(&self) -> bool {
209 self.show_help
210 }
211}
212
213fn split_layout(area: Rect, show_detail: bool) -> (Rect, Rect, Rect) {
214 let chunks = if show_detail {
215 Layout::default()
216 .direction(Direction::Vertical)
217 .constraints([
218 Constraint::Min(5),
219 Constraint::Length(8),
220 Constraint::Length(3),
221 ])
222 .split(area)
223 } else {
224 Layout::default()
225 .direction(Direction::Vertical)
226 .constraints([
227 Constraint::Min(5),
228 Constraint::Length(0),
229 Constraint::Length(3),
230 ])
231 .split(area)
232 };
233
234 (chunks[0], chunks[1], chunks[2])
235}
236
237fn render_status(
238 frame: &mut ratatui::Frame<'_>,
239 area: Rect,
240 search: &SearchState,
241 show_help: bool,
242 connection_label: &str,
243 row_count: usize,
244 processing: bool,
245) {
246 let state_label = if processing { "processing" } else { "ready" };
247 let base_status =
248 format!("Connection: {connection_label} | Rows: {row_count} | Status: {state_label}");
249 let status_text = if show_help {
250 format!("{base_status} | Help: press ? to close")
251 } else if search.is_active() {
252 format!("{base_status} | /{}", search.query())
253 } else if search.has_query() {
254 format!("{base_status} | /{} (n/N)", search.query())
255 } else {
256 format!("{base_status} | q/Esc: quit | ?: help | /: search | Enter: detail")
257 };
258
259 let paragraph = Paragraph::new(status_text)
260 .block(Block::default().borders(Borders::ALL).title("Status"))
261 .style(Style::default().fg(Color::Gray))
262 .wrap(Wrap { trim: true });
263 frame.render_widget(paragraph, area);
264}
265
266fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
267 let help_width = area.width.saturating_sub(4).min(60);
268 let help_height = area.height.saturating_sub(4).min(18);
269 let rect = Rect::new(
270 area.x + (area.width.saturating_sub(help_width)) / 2,
271 area.y + (area.height.saturating_sub(help_height)) / 2,
272 help_width,
273 help_height,
274 );
275
276 let lines = help_items()
277 .iter()
278 .map(|(key, desc)| format!("{key:<8} {desc}"))
279 .collect::<Vec<_>>()
280 .join("\n");
281
282 let help = Paragraph::new(lines)
283 .block(Block::default().borders(Borders::ALL).title("Help"))
284 .wrap(Wrap { trim: true });
285 frame.render_widget(help, rect);
286}
287
288fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
289 disable_raw_mode()?;
290 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
291 terminal.show_cursor()?;
292 Ok(())
293}
294
295pub fn is_tty() -> bool {
296 std::io::stdout().is_terminal() && std::io::stdin().is_terminal()
297}