sql_cli/chart/
tui.rs

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