Lines
64.29 %
Functions
23.73 %
Branches
100 %
//! Ratatui renderer. Returns a `RatatuiChart` wrapper that owns the
//! axis strings and f64 datasets that `ratatui::widgets::{Chart,
//! BarChart}` borrow. The CLI draws it by calling
//! `RatatuiChart::draw(&mut frame, area)` on its report screen.
//!
//! Chart kinds map to ratatui widgets as follows:
//! - `ChartKind::Line` → `Chart` with one `Dataset` per series.
//! - `ChartKind::Bar` → `BarChart`, one `BarGroup` per x-slot.
//! - `ChartKind::StackedBar` → same as `Bar`; ratatui's `BarChart`
//! does not stack natively. Documented in plan §Risk 2.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::symbols;
use ratatui::text::Line;
use ratatui::widgets::{Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, Paragraph};
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
/// Palette cycled through series. Terminal-friendly colours that read
/// against both light and dark backgrounds.
const PALETTE: &[Color] = &[
Color::Cyan,
Color::Yellow,
Color::Green,
Color::Red,
Color::Magenta,
Color::Blue,
Color::LightRed,
];
fn color_for(index: usize) -> Color {
PALETTE[index % PALETTE.len()]
}
/// One series materialised for ratatui: label, palette colour, and
/// the `(x_index, y)` points that `Dataset` / `BarChart` borrow.
struct RenderedSeries {
label: String,
color: Color,
points: Vec<(f64, f64)>,
/// Owning wrapper around the ratatui chart widgets' borrowed state.
/// Construct with `render_ratatui(&spec)`; draw with `.draw(frame,
/// area)` from the CLI render loop.
pub struct RatatuiChart {
title: String,
kind: ChartKind,
x_label: String,
y_label: String,
x_labels: Vec<String>,
notes: Vec<String>,
series: Vec<RenderedSeries>,
x_bounds: [f64; 2],
y_bounds: [f64; 2],
impl RatatuiChart {
/// Draw the chart into `area` on `frame`. The area is split into
/// a notes footer (one line per note, capped at 3) and the chart
/// body above.
pub fn draw(&self, frame: &mut Frame<'_>, area: Rect) {
let note_lines = self.notes.len().min(3) as u16;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(note_lines)])
.split(area);
match self.kind {
ChartKind::Line => self.draw_line(frame, chunks[0]),
ChartKind::Bar | ChartKind::StackedBar => self.draw_bars(frame, chunks[0]),
if note_lines > 0 {
let text: Vec<Line<'_>> = self
.notes
.iter()
.take(3)
.map(|s| Line::from(s.clone()))
.collect();
frame.render_widget(
Paragraph::new(text).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
fn draw_line(&self, frame: &mut Frame<'_>, area: Rect) {
let datasets: Vec<Dataset<'_>> = self
.series
.map(|s| {
Dataset::default()
.name(s.label.clone())
.marker(symbols::Marker::Braille)
.style(Style::default().fg(s.color))
.data(&s.points)
})
let x_labels_refs: Vec<Line<'_>> = self
.x_labels
.map(|l| Line::from(l.clone()))
let chart = Chart::new(datasets)
.block(
Block::default()
.borders(Borders::ALL)
.title(self.title.clone()),
)
.x_axis(
Axis::default()
.title(self.x_label.clone())
.style(Style::default().fg(Color::Gray))
.bounds(self.x_bounds)
.labels(x_labels_refs),
.y_axis(
.title(self.y_label.clone())
.bounds(self.y_bounds)
.labels([
format!("{:.0}", self.y_bounds[0]),
format!("{:.0}", f64::midpoint(self.y_bounds[0], self.y_bounds[1])),
format!("{:.0}", self.y_bounds[1]),
]),
frame.render_widget(chart, area);
fn draw_bars(&self, frame: &mut Frame<'_>, area: Rect) {
// BarChart wants one BarGroup per x-slot with one Bar per
// series at that slot. Ratatui takes u64 values — round.
let groups: Vec<BarGroup<'_>> = (0..self.x_labels.len())
.map(|x_idx| {
let label = self.x_labels[x_idx].clone();
let bars: Vec<Bar<'_>> = self
let value = s
.points
.find(|(x, _)| (*x as usize) == x_idx)
.map_or(0.0_f64, |(_, y)| *y);
// Bars can't represent negatives in ratatui;
// clamp at zero.
let u = if value < 0.0 {
0
} else {
value.round().max(0.0) as u64
};
Bar::default()
.value(u)
.label(Line::from(s.label.clone()))
BarGroup::default().label(Line::from(label)).bars(&bars)
let mut bar_chart = BarChart::default()
.bar_width(3)
.bar_gap(1)
.group_gap(2);
for group in groups {
bar_chart = bar_chart.data(group);
frame.render_widget(bar_chart, area);
#[must_use]
pub fn title(&self) -> &str {
&self.title
pub fn notes(&self) -> &[String] {
&self.notes
pub fn render_ratatui(spec: &ChartSpec) -> RatatuiChart {
// x-labels: union in insertion order.
let mut x_labels: Vec<String> = Vec::new();
for series in &spec.series {
for point in &series.points {
if !x_labels.iter().any(|l| l == &point.x) {
x_labels.push(point.x.clone());
// Per-series f64 dataset, x = 0-based index into x_labels.
let series: Vec<RenderedSeries> = spec
.enumerate()
.map(|(s_idx, s)| {
let points: Vec<(f64, f64)> = x_labels
.map(|(x_idx, label)| {
let y = s
.find(|p| &p.x == label)
.map_or(0.0, SeriesPoint::y_f64);
(x_idx as f64, y)
RenderedSeries {
label: s.label.clone(),
color: color_for(s_idx),
points,
let x_bounds = if x_labels.is_empty() {
[0.0, 1.0]
[0.0, (x_labels.len() - 1).max(1) as f64]
let (y_min, y_max) = y_bounds(spec);
let y_bounds = [y_min, y_max];
RatatuiChart {
title: spec.title.clone(),
kind: spec.kind,
x_label: spec.x_label.clone(),
y_label: spec.y_label.clone(),
x_labels,
notes: spec.notes.clone(),
series,
x_bounds,
y_bounds,
fn y_bounds(spec: &ChartSpec) -> (f64, f64) {
let (mut min, mut max) = (0.0_f64, 0.0_f64);
let y = point.y_f64();
if y < min {
min = y;
if y > max {
max = y;
let span = (max - min).abs().max(1.0);
let pad = span * 0.05;
(min - pad, max + pad)
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{ChartKind, Series, SeriesPoint};
fn sample(kind: ChartKind) -> ChartSpec {
ChartSpec {
title: "Test".to_string(),
kind,
x_label: "Period".to_string(),
y_label: "Amount".to_string(),
series: vec![
Series {
label: "Income".to_string(),
commodity_symbol: "USD".to_string(),
points: vec![
SeriesPoint {
x: "Jan".to_string(),
y_num: 100,
y_denom: 1,
},
x: "Feb".to_string(),
y_num: 200,
],
label: "Expense".to_string(),
y_num: 80,
y_num: 150,
notes: vec!["Showing USD only.".to_string()],
#[test]
fn render_ratatui_builds_for_each_kind() {
for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
let chart = render_ratatui(&sample(kind));
assert_eq!(chart.title(), "Test");
assert_eq!(chart.notes(), &["Showing USD only.".to_string()]);
fn render_ratatui_collects_all_x_labels_in_order() {
let chart = render_ratatui(&sample(ChartKind::Line));
assert_eq!(chart.x_labels, vec!["Jan".to_string(), "Feb".to_string()]);
fn render_ratatui_builds_series_with_zero_fill() {
// Second series misses one x-label — should fill with zero.
let spec = ChartSpec {
title: "T".to_string(),
kind: ChartKind::Line,
x_label: "X".to_string(),
y_label: "Y".to_string(),
label: "A".to_string(),
commodity_symbol: String::new(),
x: "x1".to_string(),
y_num: 1,
x: "x2".to_string(),
y_num: 2,
label: "B".to_string(),
points: vec![SeriesPoint {
y_num: 5,
}],
notes: vec![],
let chart = render_ratatui(&spec);
assert_eq!(chart.series.len(), 2);
let b_points = &chart.series[1].points;
assert_eq!(b_points.len(), 2);
// B at x1 should be zero-filled.
assert_eq!(b_points[0], (0.0, 0.0));
assert_eq!(b_points[1], (1.0, 5.0));
fn render_ratatui_empty_spec_is_safe() {
title: "Empty".to_string(),
kind: ChartKind::Bar,
x_label: String::new(),
y_label: String::new(),
series: vec![],
assert_eq!(chart.title(), "Empty");
assert!(chart.x_labels.is_empty());
assert_eq!(chart.x_bounds, [0.0, 1.0]);