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