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 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 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 let result = self.run_event_loop(&mut terminal);
59
60 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 let chunks = Layout::default()
92 .direction(Direction::Vertical)
93 .constraints([
94 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
98 .split(area);
99
100 self.render_title(frame, chunks[0]);
102
103 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 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 KeyCode::Char('q') | KeyCode::Esc => {
150 self.should_exit = true;
151 }
152
153 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 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 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}