use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{
Axis, BarChart as TuiBar, Block, Borders, Chart, Dataset as TuiDs, GraphType, Paragraph,
},
Frame, Terminal,
};
use super::{
bar_data, boxplot_stats, extract_xy, heatmap_cells, histogram_data, palette_for, pie_data,
primary_color,
};
use crate::config::{ChartSpec, ChartType};
use crate::data::Dataset;
use crate::error::Result;
fn rgb(c: (u8, u8, u8)) -> Color {
Color::Rgb(c.0, c.1, c.2)
}
pub fn run_terminal(spec: ChartSpec) -> Result<()> {
let data = spec.load_data()?;
spec.validate()?;
enable_raw_mode().map_err(io_to_opsis)?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).map_err(io_to_opsis)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(io_to_opsis)?;
let res = (|| -> Result<()> {
loop {
terminal
.draw(|f| {
let _ = render(f, f.size(), &spec, &data);
})
.map_err(io_to_opsis)?;
if event::poll(std::time::Duration::from_millis(200)).map_err(io_to_opsis)? {
if let Event::Key(k) = event::read().map_err(io_to_opsis)? {
let ctrl_c = k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('c'));
if ctrl_c
|| matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
{
break;
}
}
}
}
Ok(())
})();
disable_raw_mode().map_err(io_to_opsis)?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)
.map_err(io_to_opsis)?;
terminal.show_cursor().map_err(io_to_opsis)?;
res
}
fn io_to_opsis(e: io::Error) -> crate::error::OpsisError {
crate::error::OpsisError::Io(e)
}
pub fn render(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(if spec.chart.title.is_some() { 2 } else { 0 }),
Constraint::Min(3),
])
.split(area);
if let Some(t) = &spec.chart.title {
let title = Paragraph::new(Line::from(Span::styled(
t.clone(),
Style::default().add_modifier(Modifier::BOLD),
)));
frame.render_widget(title, chunks[0]);
}
let plot_area = chunks[1];
match spec.chart.r#type {
ChartType::Bar => render_bar(frame, plot_area, spec, data),
ChartType::Line => render_line(frame, plot_area, spec, data, false),
ChartType::Area => render_line(frame, plot_area, spec, data, true),
ChartType::Scatter => render_line(frame, plot_area, spec, data, false), ChartType::Histogram => render_histogram(frame, plot_area, spec, data),
ChartType::Pie => render_pie(frame, plot_area, spec, data),
ChartType::Heatmap => render_heatmap(frame, plot_area, spec, data),
ChartType::BoxPlot => render_boxplot(frame, plot_area, spec, data),
}
}
fn axis_label(spec: &ChartSpec, which: &str) -> String {
let ch = match which {
"x" => spec.encoding.x.as_ref(),
"y" => spec.encoding.y.as_ref(),
_ => None,
};
ch.and_then(|c| c.title.clone().or(Some(c.field.clone()))).unwrap_or_default()
}
fn render_bar(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let bars = bar_data(spec, data)?;
if bars.is_empty() { return Ok(()); }
let max = bars.iter().map(|b| b.value).fold(0.0_f64, f64::max);
let scale = if max > u64::MAX as f64 { (u64::MAX as f64) / max } else { 1.0 };
let labels: Vec<(&str, u64)> = bars
.iter()
.map(|b| (b.label.as_str(), (b.value * scale).round().max(0.0) as u64))
.collect();
let widget = TuiBar::default()
.block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
.data(&labels)
.bar_width(((area.width as usize).saturating_sub(4) / labels.len().max(1)).max(3) as u16)
.bar_style(Style::default().fg(rgb(primary_color(spec))))
.value_style(Style::default().fg(Color::White));
frame.render_widget(widget, area);
Ok(())
}
fn render_line(
frame: &mut Frame<'_>,
area: Rect,
spec: &ChartSpec,
data: &Dataset,
_fill: bool,
) -> Result<()> {
let pts = extract_xy(spec, data)?;
if pts.is_empty() { return Ok(()); }
let xs: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect();
let (min_x, max_x) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, p| {
(a.0.min(p.x), a.1.max(p.x))
});
let (min_y, max_y) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, p| {
(a.0.min(p.y), a.1.max(p.y))
});
let pad_y = ((max_y - min_y).abs() * 0.05).max(1.0);
let pad_x = ((max_x - min_x).abs() * 0.02).max(0.5);
let graph_type = if matches!(spec.chart.r#type, ChartType::Scatter) {
GraphType::Scatter
} else {
GraphType::Line
};
let ds = TuiDs::default()
.name(spec.encoding.y.as_ref().map(|c| c.field.clone()).unwrap_or_default())
.marker(if matches!(graph_type, GraphType::Scatter) {
symbols::Marker::Dot
} else {
symbols::Marker::Braille
})
.style(Style::default().fg(rgb(primary_color(spec))))
.graph_type(graph_type)
.data(&xs);
let chart = Chart::new(vec![ds])
.block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
.x_axis(
Axis::default()
.title(axis_label(spec, "x"))
.style(Style::default().fg(Color::Gray))
.bounds([min_x - pad_x, max_x + pad_x])
.labels(vec![
Span::raw(format!("{:.2}", min_x)),
Span::raw(format!("{:.2}", (min_x + max_x) / 2.0)),
Span::raw(format!("{:.2}", max_x)),
]),
)
.y_axis(
Axis::default()
.title(axis_label(spec, "y"))
.style(Style::default().fg(Color::Gray))
.bounds([min_y - pad_y, max_y + pad_y])
.labels(vec![
Span::raw(format!("{:.2}", min_y)),
Span::raw(format!("{:.2}", (min_y + max_y) / 2.0)),
Span::raw(format!("{:.2}", max_y)),
]),
);
frame.render_widget(chart, area);
Ok(())
}
fn render_histogram(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let bins = histogram_data(spec, data)?;
if bins.is_empty() { return Ok(()); }
let labels: Vec<(&str, u64)> = bins
.iter()
.map(|b| (b.label.as_str(), b.value.max(0.0) as u64))
.collect();
let widget = TuiBar::default()
.block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
.data(&labels)
.bar_width(((area.width as usize).saturating_sub(4) / labels.len().max(1)).max(2) as u16)
.bar_style(Style::default().fg(rgb(primary_color(spec))));
frame.render_widget(widget, area);
Ok(())
}
fn render_pie(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let slices = pie_data(spec, data)?;
let total: f64 = slices.iter().map(|s| s.value).sum();
let palette = palette_for(spec);
let mut lines: Vec<Line> = Vec::new();
for (i, s) in slices.iter().enumerate() {
let pct = if total > 0.0 { 100.0 * s.value / total } else { 0.0 };
let bar_len = ((area.width as f64 - 32.0).max(4.0) * (pct / 100.0)) as usize;
let bar: String = "█".repeat(bar_len);
let color = palette.get(i % palette.len()).copied().unwrap_or((76, 120, 168));
lines.push(Line::from(vec![
Span::raw(format!("{:>14} ", truncate(&s.label, 14))),
Span::styled(bar, Style::default().fg(rgb(color))),
Span::raw(format!(" {:>5.1}% ({:.2})", pct, s.value)),
]));
}
let p = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(spec.title().to_string()));
frame.render_widget(p, area);
Ok(())
}
fn render_heatmap(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let (xs, ys, cells) = heatmap_cells(spec, data)?;
if cells.is_empty() { return Ok(()); }
let min = cells.iter().map(|c| c.value).fold(f64::INFINITY, f64::min);
let max = cells.iter().map(|c| c.value).fold(f64::NEG_INFINITY, f64::max);
let span = (max - min).max(f64::EPSILON);
let glyphs = ['·', '░', '▒', '▓', '█'];
let mut grid: Vec<Vec<char>> = vec![vec![' '; xs.len()]; ys.len()];
for c in &cells {
let t = ((c.value - min) / span).clamp(0.0, 1.0);
let idx = ((t * (glyphs.len() - 1) as f64).round() as usize).min(glyphs.len() - 1);
grid[c.y_idx][c.x_idx] = glyphs[idx];
}
let label_w = ys.iter().map(|s| s.len()).max().unwrap_or(0).min(12) as u16;
let mut lines: Vec<Line> = Vec::new();
for (yi, row) in grid.iter().enumerate() {
let label = truncate(&ys[yi], label_w as usize);
let cells_str: String = row.iter().collect();
lines.push(Line::from(vec![
Span::styled(format!("{:>w$} ", label, w = label_w as usize), Style::default().fg(Color::Gray)),
Span::styled(cells_str, Style::default().fg(rgb(primary_color(spec)))),
]));
}
let header: String = std::iter::repeat(' ')
.take(label_w as usize + 1)
.chain(xs.iter().map(|s| s.chars().next().unwrap_or(' ')))
.collect();
lines.push(Line::from(Span::styled(header, Style::default().fg(Color::DarkGray))));
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title(format!("{} (min {:.2}, max {:.2})", spec.title(), min, max)),
);
frame.render_widget(p, area);
Ok(())
}
fn render_boxplot(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
let stats = boxplot_stats(spec, data)?;
if stats.is_empty() { return Ok(()); }
let global_min = stats.iter().map(|s| s.min).fold(f64::INFINITY, f64::min);
let global_max = stats.iter().map(|s| s.max).fold(f64::NEG_INFINITY, f64::max);
let span = (global_max - global_min).max(f64::EPSILON);
let label_w = stats.iter().map(|s| s.label.len()).max().unwrap_or(0).min(14);
let bar_w = (area.width as usize).saturating_sub(label_w + 6).max(10);
let scale = |v: f64| -> usize {
((v - global_min) / span * (bar_w as f64 - 1.0)).round() as usize
};
let color = rgb(primary_color(spec));
let mut lines: Vec<Line> = Vec::new();
for s in &stats {
let mut row: Vec<char> = vec![' '; bar_w];
let lo = scale(s.min);
let q1 = scale(s.q1);
let med = scale(s.median);
let q3 = scale(s.q3);
let hi = scale(s.max);
for i in lo..=hi { if i < bar_w { row[i] = '─'; } }
for i in q1..=q3 { if i < bar_w { row[i] = '█'; } }
if med < bar_w { row[med] = '┃'; }
let s_str: String = row.into_iter().collect();
lines.push(Line::from(vec![
Span::styled(format!("{:>w$} ", truncate(&s.label, label_w), w = label_w), Style::default().fg(Color::Gray)),
Span::styled(s_str, Style::default().fg(color)),
Span::raw(format!(" [{:.2}, {:.2}]", s.min, s.max)),
]));
}
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title(spec.title().to_string()),
);
frame.render_widget(p, area);
Ok(())
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else {
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('…');
out
}
}