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