sql_cli/ui/
tui_app.rs

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        // Initial draw
57        terminal.draw(|f| self.ui(f))?;
58
59        loop {
60            // Only redraw when we get an event
61            if let Event::Key(key) = event::read()? {
62                let needs_redraw = true; // We'll optimize this later
63                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; // Exit app
71                        }
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                            // Could add command history here
91                        } else {
92                            // Handle cursor movement in input
93                            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                // Redraw after handling the event
116                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        // Basic completion - could be enhanced with proper parsing
146        let input_text = self.input.value().to_string();
147        let suggestions = self.get_completions(&input_text);
148
149        if !suggestions.is_empty() {
150            // For now, just complete the first suggestion
151            // In a full implementation, you'd show a popup with options
152            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                    // Move cursor to end
163                    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(); // Get actual cursor position
177        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), // Command input
217                Constraint::Min(5),    // Results area
218                Constraint::Length(1), // Status bar
219            ])
220            .split(f.area());
221
222        // Command input area
223        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        // Set cursor position when in command mode
237        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        // Results area
245        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        // Status bar
273        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        // Help popup if active
292        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        // Get headers from first record or from select fields
309        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        // Calculate column widths
324        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        // Use VirtualTable for efficient rendering
336        let header_refs: Vec<&str> = headers.iter().map(|s| s.as_str()).collect();
337        let current_row = self.virtual_table_state.selected + 1; // 1-based for display
338        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        // Clone is needed here due to Rust's borrowing rules with closures
346        // This is a small struct so the performance impact should be minimal
347        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
391// Helper function to create a centered rect
392fn 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    // Setup terminal
414    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    // Get API URL from environment or use default
421    let api_url =
422        std::env::var("TRADE_API_URL").unwrap_or_else(|_| "http://localhost:5000".to_string());
423
424    // Create and run app
425    let mut app = TuiApp::new(&api_url);
426    let res = app.run(&mut terminal);
427
428    // Restore terminal
429    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        // Error handled in TUI status message instead of stdout
439    }
440
441    Ok(())
442}