1use crate::api_client::{ApiClient, QueryResponse};
2use crate::cursor_aware_parser::CursorAwareParser;
3use crate::parser::SqlParser;
4use anyhow::Result;
5use crossterm::{
6 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
7 execute,
8 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use ratatui::{
11 backend::{Backend, CrosstermBackend},
12 layout::{Constraint, Direction, Layout, Rect},
13 style::{Color, Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, Clear, Paragraph},
16 Frame, Terminal,
17};
18use std::io;
19use tui_input::{backend::crossterm::EventHandler, Input};
20
21#[derive(Clone, PartialEq)]
22enum AppMode {
23 Command,
24 Results,
25}
26
27#[derive(Clone)]
28pub struct TuiApp {
29 api_client: ApiClient,
30 input: Input,
31 mode: AppMode,
32 results: Option<QueryResponse>,
33 virtual_table_state: crate::virtual_table::VirtualTableState,
34 show_help: bool,
35 status_message: String,
36 sql_parser: SqlParser,
37 cursor_parser: CursorAwareParser,
38}
39
40impl TuiApp {
41 #[must_use]
42 pub fn new(api_url: &str) -> Self {
43 Self {
44 api_client: ApiClient::new(api_url),
45 input: Input::default(),
46 mode: AppMode::Command,
47 results: None,
48 virtual_table_state: crate::virtual_table::VirtualTableState::new(),
49 show_help: false,
50 status_message: "Ready - Type SQL query and press Enter (Enhanced parser)".to_string(),
51 sql_parser: SqlParser::new(),
52 cursor_parser: CursorAwareParser::new(),
53 }
54 }
55
56 pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
57 terminal.draw(|f| self.ui(f))?;
59
60 loop {
61 if let Event::Key(key) = event::read()? {
63 let needs_redraw = true; match key.code {
65 KeyCode::Esc => {
66 if self.show_help {
67 self.show_help = false;
68 } else if self.mode == AppMode::Results {
69 self.mode = AppMode::Command;
70 } else {
71 break; }
73 }
74 KeyCode::F(1) => {
75 self.show_help = !self.show_help;
76 }
77 KeyCode::Enter => {
78 if self.mode == AppMode::Command && !self.input.value().trim().is_empty() {
79 self.execute_query();
80 }
81 }
82 KeyCode::Tab => {
83 if self.mode == AppMode::Command {
84 self.handle_tab_completion();
85 }
86 }
87 KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
88 if self.mode == AppMode::Results {
89 self.handle_navigation(key.code);
90 } else if key.code == KeyCode::Up || key.code == KeyCode::Down {
91 } else {
93 self.input.handle_event(&Event::Key(key));
95 }
96 }
97 KeyCode::PageUp | KeyCode::PageDown => {
98 if self.mode == AppMode::Results {
99 self.handle_navigation(key.code);
100 }
101 }
102 KeyCode::Char('g' | 'G') => {
103 if self.mode == AppMode::Results {
104 self.handle_navigation(key.code);
105 } else {
106 self.input.handle_event(&Event::Key(key));
107 }
108 }
109 _ => {
110 if self.mode == AppMode::Command {
111 self.input.handle_event(&Event::Key(key));
112 }
113 }
114 }
115
116 if needs_redraw {
118 terminal.draw(|f| self.ui(f))?;
119 }
120 }
121 }
122 Ok(())
123 }
124
125 fn execute_query(&mut self) {
126 let query = self.input.value().trim();
127 self.status_message = format!("Executing: {query}");
128
129 match self.api_client.query_trades(query) {
130 Ok(response) => {
131 self.results = Some(response);
132 self.mode = AppMode::Results;
133 self.virtual_table_state.select(0);
134 self.status_message = format!(
135 "Query executed successfully - {} rows",
136 self.results.as_ref().unwrap().data.len()
137 );
138 }
139 Err(e) => {
140 self.status_message = format!("Error: {e}");
141 }
142 }
143 }
144
145 fn handle_tab_completion(&mut self) {
146 let input_text = self.input.value().to_string();
148 let suggestions = self.get_completions(&input_text);
149
150 if !suggestions.is_empty() {
151 let suggestion = &suggestions[0];
154 let words: Vec<&str> = input_text.split_whitespace().collect();
155 if let Some(last_word) = words.last() {
156 if suggestion
157 .to_lowercase()
158 .starts_with(&last_word.to_lowercase())
159 {
160 let new_input =
161 format!("{}{} ", input_text.trim_end_matches(last_word), suggestion);
162 self.input = Input::from(new_input);
163 while self.input.cursor() < self.input.value().len() {
165 self.input
166 .handle_event(&Event::Key(crossterm::event::KeyEvent::new(
167 KeyCode::Right,
168 KeyModifiers::NONE,
169 )));
170 }
171 }
172 }
173 }
174 }
175
176 fn get_completions(&mut self, input: &str) -> Vec<String> {
177 let cursor_pos = self.input.cursor(); let result = self.cursor_parser.get_completions(input, cursor_pos);
179 result.suggestions
180 }
181
182 fn handle_navigation(&mut self, key: KeyCode) {
183 if let Some(results) = &self.results {
184 let num_rows = results.data.len();
185 if num_rows == 0 {
186 return;
187 }
188
189 match key {
190 KeyCode::Up => {
191 self.virtual_table_state.scroll_up(1);
192 }
193 KeyCode::Down => {
194 self.virtual_table_state.scroll_down(1, num_rows);
195 }
196 KeyCode::PageUp => {
197 self.virtual_table_state.page_up();
198 }
199 KeyCode::PageDown => {
200 self.virtual_table_state.page_down(num_rows);
201 }
202 KeyCode::Char('g') => {
203 self.virtual_table_state.goto_top();
204 }
205 KeyCode::Char('G') => {
206 self.virtual_table_state.goto_bottom(num_rows);
207 }
208 _ => {}
209 }
210 }
211 }
212
213 fn ui(&self, f: &mut Frame) {
214 let chunks = Layout::default()
215 .direction(Direction::Vertical)
216 .constraints([
217 Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
221 .split(f.area());
222
223 let input_block = Block::default().borders(Borders::ALL).title("SQL Command");
225
226 let input_style = if self.mode == AppMode::Command {
227 Style::default().fg(Color::Yellow)
228 } else {
229 Style::default().fg(Color::Gray)
230 };
231
232 let input_paragraph = Paragraph::new(self.input.value())
233 .block(input_block)
234 .style(input_style);
235 f.render_widget(input_paragraph, chunks[0]);
236
237 if self.mode == AppMode::Command {
239 f.set_cursor_position((
240 chunks[0].x + self.input.visual_cursor() as u16 + 1,
241 chunks[0].y + 1,
242 ));
243 }
244
245 if let Some(results) = &self.results {
247 self.render_results(f, chunks[1], results);
248 } else {
249 let help_text = if self.mode == AppMode::Command {
250 vec![
251 Line::from("Enter your SQL query above and press Enter to execute"),
252 Line::from(""),
253 Line::from("Examples:"),
254 Line::from(" SELECT * FROM trade_deal"),
255 Line::from(" SELECT dealId, price FROM trade_deal WHERE price > 100"),
256 Line::from(" SELECT * FROM trade_deal WHERE ticker = 'AAPL'"),
257 Line::from(""),
258 Line::from("Controls:"),
259 Line::from(" Tab - Auto-complete"),
260 Line::from(" F1 - Toggle help"),
261 Line::from(" Esc - Exit"),
262 ]
263 } else {
264 vec![Line::from("No results to display")]
265 };
266
267 let help_paragraph = Paragraph::new(help_text)
268 .block(Block::default().borders(Borders::ALL).title("Help"))
269 .wrap(ratatui::widgets::Wrap { trim: true });
270 f.render_widget(help_paragraph, chunks[1]);
271 }
272
273 let status_line = Line::from(vec![
275 Span::styled(&self.status_message, Style::default().fg(Color::White)),
276 Span::raw(" | "),
277 Span::styled(
278 match self.mode {
279 AppMode::Command => "CMD",
280 AppMode::Results => "VIEW",
281 },
282 Style::default()
283 .fg(Color::Cyan)
284 .add_modifier(Modifier::BOLD),
285 ),
286 Span::raw(" | F1=Help | Esc=Back/Exit"),
287 ]);
288
289 let status = Paragraph::new(status_line).style(Style::default().bg(Color::DarkGray));
290 f.render_widget(status, chunks[2]);
291
292 if self.show_help {
294 self.render_help_popup(f);
295 }
296 }
297
298 fn render_results(&self, f: &mut Frame, area: Rect, results: &QueryResponse) {
299 let data = &results.data;
300 let select_fields = &results.query.select;
301
302 if data.is_empty() {
303 let no_data = Paragraph::new("No data returned")
304 .block(Block::default().borders(Borders::ALL).title("Results"));
305 f.render_widget(no_data, area);
306 return;
307 }
308
309 let headers: Vec<String> = if select_fields.contains(&"*".to_string()) {
311 if let Some(first) = data.first() {
312 if let Some(obj) = first.as_object() {
313 obj.keys().cloned().collect()
314 } else {
315 vec![]
316 }
317 } else {
318 vec![]
319 }
320 } else {
321 select_fields.clone()
322 };
323
324 let num_cols = headers.len();
326 let col_width = if num_cols > 0 {
327 (area.width.saturating_sub(2)) / num_cols as u16
328 } else {
329 10
330 };
331
332 let widths: Vec<Constraint> = (0..num_cols)
333 .map(|_| Constraint::Length(col_width))
334 .collect();
335
336 let header_refs: Vec<&str> = headers.iter().map(std::string::String::as_str).collect();
338 let current_row = self.virtual_table_state.selected + 1; let virtual_table = crate::virtual_table::VirtualTable::new(header_refs, data, widths)
340 .block(Block::default().borders(Borders::ALL).title(format!(
341 "Results (Row {}/{}) - Use ↑↓ to navigate, Esc to return, g=top G=bottom",
342 current_row,
343 data.len()
344 )));
345
346 f.render_stateful_widget(virtual_table, area, &mut self.virtual_table_state.clone());
349 }
350
351 fn render_help_popup(&self, f: &mut Frame) {
352 let area = centered_rect(80, 60, f.area());
353 f.render_widget(Clear, area);
354
355 let help_text = vec![
356 Line::from(vec![Span::styled(
357 "SQL CLI Help",
358 Style::default()
359 .fg(Color::Yellow)
360 .add_modifier(Modifier::BOLD),
361 )]),
362 Line::from(""),
363 Line::from("Command Mode:"),
364 Line::from(" Enter - Execute query"),
365 Line::from(" Tab - Auto-complete"),
366 Line::from(" Esc - Exit application"),
367 Line::from(""),
368 Line::from("Results Mode:"),
369 Line::from(" ↑↓ - Navigate rows"),
370 Line::from(" Page Up/Down - Navigate pages"),
371 Line::from(" Esc - Return to command mode"),
372 Line::from(""),
373 Line::from("Global:"),
374 Line::from(" F1 - Toggle this help"),
375 Line::from(""),
376 Line::from("Example Queries:"),
377 Line::from(" SELECT * FROM trade_deal"),
378 Line::from(" SELECT dealId, price FROM trade_deal WHERE price > 100"),
379 Line::from(" SELECT * FROM trade_deal WHERE ticker = 'AAPL'"),
380 Line::from(" SELECT * FROM trade_deal WHERE counterparty.Contains('Goldman')"),
381 Line::from(" SELECT * FROM trade_deal ORDER BY price DESC"),
382 ];
383
384 let help_popup = Paragraph::new(help_text)
385 .block(Block::default().borders(Borders::ALL).title("Help"))
386 .wrap(ratatui::widgets::Wrap { trim: true });
387
388 f.render_widget(help_popup, area);
389 }
390}
391
392fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
394 let popup_layout = Layout::default()
395 .direction(Direction::Vertical)
396 .constraints([
397 Constraint::Percentage((100 - percent_y) / 2),
398 Constraint::Percentage(percent_y),
399 Constraint::Percentage((100 - percent_y) / 2),
400 ])
401 .split(r);
402
403 Layout::default()
404 .direction(Direction::Horizontal)
405 .constraints([
406 Constraint::Percentage((100 - percent_x) / 2),
407 Constraint::Percentage(percent_x),
408 Constraint::Percentage((100 - percent_x) / 2),
409 ])
410 .split(popup_layout[1])[1]
411}
412
413pub fn run_tui_app() -> Result<()> {
414 enable_raw_mode()?;
416 let mut stdout = io::stdout();
417 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
418 let backend = CrosstermBackend::new(stdout);
419 let mut terminal = Terminal::new(backend)?;
420
421 let api_url =
423 std::env::var("TRADE_API_URL").unwrap_or_else(|_| "http://localhost:5000".to_string());
424
425 let mut app = TuiApp::new(&api_url);
427 let res = app.run(&mut terminal);
428
429 disable_raw_mode()?;
431 execute!(
432 terminal.backend_mut(),
433 LeaveAlternateScreen,
434 DisableMouseCapture
435 )?;
436 terminal.show_cursor()?;
437
438 if let Err(_err) = res {
439 }
441
442 Ok(())
443}