sql_cli/chart/
tui.rs

1use crate::chart::{engine::ChartEngine, renderers::LineRenderer, types::*};
2use anyhow::Result;
3use crossterm::{
4    event::{self, Event, KeyCode, KeyEventKind},
5    execute,
6    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{
9    backend::CrosstermBackend,
10    layout::{Constraint, Direction, Layout, Margin},
11    style::{Color, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, Paragraph},
14    Terminal,
15};
16use std::io;
17
18pub struct ChartTui {
19    config: ChartConfig,
20    data: Option<DataSeries>,
21    renderer: Option<LineRenderer>,
22    should_exit: bool,
23}
24
25impl ChartTui {
26    pub fn new(config: ChartConfig) -> Self {
27        Self {
28            config,
29            data: None,
30            renderer: None,
31            should_exit: false,
32        }
33    }
34
35    pub fn run(&mut self, mut chart_engine: ChartEngine) -> Result<()> {
36        // Initialize terminal
37        enable_raw_mode()?;
38        let mut stdout = io::stdout();
39        execute!(stdout, EnterAlternateScreen)?;
40        let backend = CrosstermBackend::new(stdout);
41        let mut terminal = Terminal::new(backend)?;
42
43        // Execute query and prepare data
44        let data = chart_engine.execute_chart_query(&self.config)?;
45        let viewport = ChartViewport::new(data.x_range, data.y_range);
46        let mut renderer = LineRenderer::new(viewport);
47        renderer.viewport_mut().auto_scale(&data);
48
49        self.data = Some(data);
50        self.renderer = Some(renderer);
51
52        // Main event loop
53        let result = self.run_event_loop(&mut terminal);
54
55        // Restore terminal
56        disable_raw_mode()?;
57        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
58        terminal.show_cursor()?;
59
60        result
61    }
62
63    fn run_event_loop(
64        &mut self,
65        terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
66    ) -> Result<()> {
67        while !self.should_exit {
68            terminal.draw(|frame| self.render_chart(frame))?;
69
70            if event::poll(std::time::Duration::from_millis(100))? {
71                if let Event::Key(key) = event::read()? {
72                    if key.kind == KeyEventKind::Press {
73                        self.handle_key_event(key.code);
74                    }
75                }
76            }
77        }
78
79        Ok(())
80    }
81
82    fn render_chart(&mut self, frame: &mut ratatui::Frame) {
83        let area = frame.area();
84
85        // Create layout
86        let chunks = Layout::default()
87            .direction(Direction::Vertical)
88            .constraints([
89                Constraint::Length(3), // Title
90                Constraint::Min(0),    // Chart
91                Constraint::Length(3), // Controls
92            ])
93            .split(area);
94
95        // Render title
96        self.render_title(frame, chunks[0]);
97
98        // Render chart
99        if let (Some(data), Some(renderer)) = (&self.data, &self.renderer) {
100            let chart_area = chunks[1].inner(Margin {
101                horizontal: 1,
102                vertical: 1,
103            });
104            renderer.render(frame, chart_area, data, &self.config);
105        }
106
107        // Render controls
108        self.render_controls(frame, chunks[2]);
109    }
110
111    fn render_title(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
112        let title = Paragraph::new(Line::from(vec![Span::styled(
113            &self.config.title,
114            Style::default().fg(Color::Yellow),
115        )]))
116        .block(Block::default().borders(Borders::ALL));
117
118        frame.render_widget(title, area);
119    }
120
121    fn render_controls(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
122        let controls = vec![
123            Span::raw("Controls: "),
124            Span::styled("hjkl", Style::default().fg(Color::Cyan)),
125            Span::raw(" pan • "),
126            Span::styled("+/-", Style::default().fg(Color::Cyan)),
127            Span::raw(" zoom • "),
128            Span::styled("r", Style::default().fg(Color::Cyan)),
129            Span::raw(" reset • "),
130            Span::styled("q", Style::default().fg(Color::Cyan)),
131            Span::raw(" quit"),
132        ];
133
134        let paragraph =
135            Paragraph::new(Line::from(controls)).block(Block::default().borders(Borders::ALL));
136
137        frame.render_widget(paragraph, area);
138    }
139
140    fn handle_key_event(&mut self, key_code: KeyCode) {
141        if let Some(renderer) = &mut self.renderer {
142            match key_code {
143                // Exit
144                KeyCode::Char('q') | KeyCode::Esc => {
145                    self.should_exit = true;
146                }
147
148                // Pan (vim-style)
149                KeyCode::Char('h') => {
150                    renderer.viewport_mut().pan(-1.0, 0.0);
151                }
152                KeyCode::Char('l') => {
153                    renderer.viewport_mut().pan(1.0, 0.0);
154                }
155                KeyCode::Char('k') => {
156                    renderer.viewport_mut().pan(0.0, 1.0);
157                }
158                KeyCode::Char('j') => {
159                    renderer.viewport_mut().pan(0.0, -1.0);
160                }
161
162                // Zoom
163                KeyCode::Char('+') | KeyCode::Char('=') => {
164                    let viewport = renderer.viewport();
165                    let center_x = (viewport.x_min + viewport.x_max) / 2.0;
166                    let center_y = (viewport.y_min + viewport.y_max) / 2.0;
167                    renderer.viewport_mut().zoom(1.2, center_x, center_y);
168                }
169                KeyCode::Char('-') => {
170                    let viewport = renderer.viewport();
171                    let center_x = (viewport.x_min + viewport.x_max) / 2.0;
172                    let center_y = (viewport.y_min + viewport.y_max) / 2.0;
173                    renderer.viewport_mut().zoom(0.8, center_x, center_y);
174                }
175
176                // Reset view
177                KeyCode::Char('r') => {
178                    if let Some(data) = &self.data {
179                        renderer.viewport_mut().auto_scale(data);
180                    }
181                }
182
183                _ => {}
184            }
185        }
186    }
187}