Skip to main content

plotting/
ratatui.rs

1//! Ratatui renderer. Returns a `RatatuiChart` wrapper that owns the
2//! axis strings and f64 datasets that `ratatui::widgets::{Chart,
3//! BarChart}` borrow. The CLI draws it by calling
4//! `RatatuiChart::draw(&mut frame, area)` on its report screen.
5//!
6//! Chart kinds map to ratatui widgets as follows:
7//!
8//! - `ChartKind::Line`  → `Chart` with one `Dataset` per series.
9//! - `ChartKind::Bar`   → `BarChart`, one `BarGroup` per x-slot.
10//! - `ChartKind::StackedBar` → same as `Bar`; ratatui's `BarChart`
11//!   does not stack natively. Documented in plan §Risk 2.
12
13use ratatui::Frame;
14use ratatui::layout::{Constraint, Direction, Layout, Rect};
15use ratatui::style::{Color, Style};
16use ratatui::symbols;
17use ratatui::text::Line;
18use ratatui::widgets::{Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, Paragraph};
19
20use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
21
22/// Palette cycled through series. Terminal-friendly colours that read
23/// against both light and dark backgrounds.
24const PALETTE: &[Color] = &[
25    Color::Cyan,
26    Color::Yellow,
27    Color::Green,
28    Color::Red,
29    Color::Magenta,
30    Color::Blue,
31    Color::LightRed,
32];
33
34fn color_for(index: usize) -> Color {
35    PALETTE[index % PALETTE.len()]
36}
37
38/// One series materialised for ratatui: label, palette colour, and
39/// the `(x_index, y)` points that `Dataset` / `BarChart` borrow.
40struct RenderedSeries {
41    label: String,
42    color: Color,
43    points: Vec<(f64, f64)>,
44}
45
46/// Owning wrapper around the ratatui chart widgets' borrowed state.
47/// Construct with `render_ratatui(&spec)`; draw with `.draw(frame,
48/// area)` from the CLI render loop.
49pub struct RatatuiChart {
50    title: String,
51    kind: ChartKind,
52    x_label: String,
53    y_label: String,
54    x_labels: Vec<String>,
55    notes: Vec<String>,
56    series: Vec<RenderedSeries>,
57    x_bounds: [f64; 2],
58    y_bounds: [f64; 2],
59}
60
61impl RatatuiChart {
62    /// Draw the chart into `area` on `frame`. The area is split into
63    /// a notes footer (one line per note, capped at 3) and the chart
64    /// body above.
65    pub fn draw(&self, frame: &mut Frame<'_>, area: Rect) {
66        let note_lines = self.notes.len().min(3) as u16;
67        let chunks = Layout::default()
68            .direction(Direction::Vertical)
69            .constraints([Constraint::Min(3), Constraint::Length(note_lines)])
70            .split(area);
71
72        match self.kind {
73            ChartKind::Line => self.draw_line(frame, chunks[0]),
74            ChartKind::Bar | ChartKind::StackedBar => self.draw_bars(frame, chunks[0]),
75        }
76
77        if note_lines > 0 {
78            let text: Vec<Line<'_>> = self
79                .notes
80                .iter()
81                .take(3)
82                .map(|s| Line::from(s.clone()))
83                .collect();
84            frame.render_widget(
85                Paragraph::new(text).style(Style::default().fg(Color::DarkGray)),
86                chunks[1],
87            );
88        }
89    }
90
91    fn draw_line(&self, frame: &mut Frame<'_>, area: Rect) {
92        let datasets: Vec<Dataset<'_>> = self
93            .series
94            .iter()
95            .map(|s| {
96                Dataset::default()
97                    .name(s.label.clone())
98                    .marker(symbols::Marker::Braille)
99                    .style(Style::default().fg(s.color))
100                    .data(&s.points)
101            })
102            .collect();
103
104        let x_labels_refs: Vec<Line<'_>> = self
105            .x_labels
106            .iter()
107            .map(|l| Line::from(l.clone()))
108            .collect();
109
110        let chart = Chart::new(datasets)
111            .block(
112                Block::default()
113                    .borders(Borders::ALL)
114                    .title(self.title.clone()),
115            )
116            .x_axis(
117                Axis::default()
118                    .title(self.x_label.clone())
119                    .style(Style::default().fg(Color::Gray))
120                    .bounds(self.x_bounds)
121                    .labels(x_labels_refs),
122            )
123            .y_axis(
124                Axis::default()
125                    .title(self.y_label.clone())
126                    .style(Style::default().fg(Color::Gray))
127                    .bounds(self.y_bounds)
128                    .labels([
129                        format!("{:.0}", self.y_bounds[0]),
130                        format!("{:.0}", f64::midpoint(self.y_bounds[0], self.y_bounds[1])),
131                        format!("{:.0}", self.y_bounds[1]),
132                    ]),
133            );
134
135        frame.render_widget(chart, area);
136    }
137
138    fn draw_bars(&self, frame: &mut Frame<'_>, area: Rect) {
139        // BarChart wants one BarGroup per x-slot with one Bar per
140        // series at that slot. Ratatui takes u64 values — round.
141        let groups: Vec<BarGroup<'_>> = (0..self.x_labels.len())
142            .map(|x_idx| {
143                let label = self.x_labels[x_idx].clone();
144                let bars: Vec<Bar<'_>> = self
145                    .series
146                    .iter()
147                    .map(|s| {
148                        let value = s
149                            .points
150                            .iter()
151                            .find(|(x, _)| (*x as usize) == x_idx)
152                            .map_or(0.0_f64, |(_, y)| *y);
153                        // Bars can't represent negatives in ratatui;
154                        // clamp at zero.
155                        let u = if value < 0.0 {
156                            0
157                        } else {
158                            value.round().max(0.0) as u64
159                        };
160                        Bar::default()
161                            .value(u)
162                            .label(Line::from(s.label.clone()))
163                            .style(Style::default().fg(s.color))
164                    })
165                    .collect();
166                BarGroup::default().label(Line::from(label)).bars(&bars)
167            })
168            .collect();
169
170        let mut bar_chart = BarChart::default()
171            .block(
172                Block::default()
173                    .borders(Borders::ALL)
174                    .title(self.title.clone()),
175            )
176            .bar_width(3)
177            .bar_gap(1)
178            .group_gap(2);
179        for group in groups {
180            bar_chart = bar_chart.data(group);
181        }
182        frame.render_widget(bar_chart, area);
183    }
184
185    #[must_use]
186    pub fn title(&self) -> &str {
187        &self.title
188    }
189
190    #[must_use]
191    pub fn notes(&self) -> &[String] {
192        &self.notes
193    }
194}
195
196#[must_use]
197pub fn render_ratatui(spec: &ChartSpec) -> RatatuiChart {
198    // x-labels: union in insertion order.
199    let mut x_labels: Vec<String> = Vec::new();
200    for series in &spec.series {
201        for point in &series.points {
202            if !x_labels.iter().any(|l| l == &point.x) {
203                x_labels.push(point.x.clone());
204            }
205        }
206    }
207
208    // Per-series f64 dataset, x = 0-based index into x_labels.
209    let series: Vec<RenderedSeries> = spec
210        .series
211        .iter()
212        .enumerate()
213        .map(|(s_idx, s)| {
214            let points: Vec<(f64, f64)> = x_labels
215                .iter()
216                .enumerate()
217                .map(|(x_idx, label)| {
218                    let y = s
219                        .points
220                        .iter()
221                        .find(|p| &p.x == label)
222                        .map_or(0.0, SeriesPoint::y_f64);
223                    (x_idx as f64, y)
224                })
225                .collect();
226            RenderedSeries {
227                label: s.label.clone(),
228                color: color_for(s_idx),
229                points,
230            }
231        })
232        .collect();
233
234    let x_bounds = if x_labels.is_empty() {
235        [0.0, 1.0]
236    } else {
237        [0.0, (x_labels.len() - 1).max(1) as f64]
238    };
239
240    let (y_min, y_max) = y_bounds(spec);
241    let y_bounds = [y_min, y_max];
242
243    RatatuiChart {
244        title: spec.title.clone(),
245        kind: spec.kind,
246        x_label: spec.x_label.clone(),
247        y_label: spec.y_label.clone(),
248        x_labels,
249        notes: spec.notes.clone(),
250        series,
251        x_bounds,
252        y_bounds,
253    }
254}
255
256fn y_bounds(spec: &ChartSpec) -> (f64, f64) {
257    let (mut min, mut max) = (0.0_f64, 0.0_f64);
258    for series in &spec.series {
259        for point in &series.points {
260            let y = point.y_f64();
261            if y < min {
262                min = y;
263            }
264            if y > max {
265                max = y;
266            }
267        }
268    }
269    let span = (max - min).abs().max(1.0);
270    let pad = span * 0.05;
271    (min - pad, max + pad)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::spec::{ChartKind, Series, SeriesPoint};
278
279    fn sample(kind: ChartKind) -> ChartSpec {
280        ChartSpec {
281            title: "Test".to_string(),
282            kind,
283            x_label: "Period".to_string(),
284            y_label: "Amount".to_string(),
285            series: vec![
286                Series {
287                    label: "Income".to_string(),
288                    commodity_symbol: "USD".to_string(),
289                    points: vec![
290                        SeriesPoint {
291                            x: "Jan".to_string(),
292                            y_num: 100,
293                            y_denom: 1,
294                        },
295                        SeriesPoint {
296                            x: "Feb".to_string(),
297                            y_num: 200,
298                            y_denom: 1,
299                        },
300                    ],
301                },
302                Series {
303                    label: "Expense".to_string(),
304                    commodity_symbol: "USD".to_string(),
305                    points: vec![
306                        SeriesPoint {
307                            x: "Jan".to_string(),
308                            y_num: 80,
309                            y_denom: 1,
310                        },
311                        SeriesPoint {
312                            x: "Feb".to_string(),
313                            y_num: 150,
314                            y_denom: 1,
315                        },
316                    ],
317                },
318            ],
319            notes: vec!["Showing USD only.".to_string()],
320        }
321    }
322
323    #[test]
324    fn render_ratatui_builds_for_each_kind() {
325        for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
326            let chart = render_ratatui(&sample(kind));
327            assert_eq!(chart.title(), "Test");
328            assert_eq!(chart.notes(), &["Showing USD only.".to_string()]);
329        }
330    }
331
332    #[test]
333    fn render_ratatui_collects_all_x_labels_in_order() {
334        let chart = render_ratatui(&sample(ChartKind::Line));
335        assert_eq!(chart.x_labels, vec!["Jan".to_string(), "Feb".to_string()]);
336    }
337
338    #[test]
339    fn render_ratatui_builds_series_with_zero_fill() {
340        // Second series misses one x-label — should fill with zero.
341        let spec = ChartSpec {
342            title: "T".to_string(),
343            kind: ChartKind::Line,
344            x_label: "X".to_string(),
345            y_label: "Y".to_string(),
346            series: vec![
347                Series {
348                    label: "A".to_string(),
349                    commodity_symbol: String::new(),
350                    points: vec![
351                        SeriesPoint {
352                            x: "x1".to_string(),
353                            y_num: 1,
354                            y_denom: 1,
355                        },
356                        SeriesPoint {
357                            x: "x2".to_string(),
358                            y_num: 2,
359                            y_denom: 1,
360                        },
361                    ],
362                },
363                Series {
364                    label: "B".to_string(),
365                    commodity_symbol: String::new(),
366                    points: vec![SeriesPoint {
367                        x: "x2".to_string(),
368                        y_num: 5,
369                        y_denom: 1,
370                    }],
371                },
372            ],
373            notes: vec![],
374        };
375        let chart = render_ratatui(&spec);
376        assert_eq!(chart.series.len(), 2);
377        let b_points = &chart.series[1].points;
378        assert_eq!(b_points.len(), 2);
379        // B at x1 should be zero-filled.
380        assert_eq!(b_points[0], (0.0, 0.0));
381        assert_eq!(b_points[1], (1.0, 5.0));
382    }
383
384    #[test]
385    fn render_ratatui_empty_spec_is_safe() {
386        let spec = ChartSpec {
387            title: "Empty".to_string(),
388            kind: ChartKind::Bar,
389            x_label: String::new(),
390            y_label: String::new(),
391            series: vec![],
392            notes: vec![],
393        };
394        let chart = render_ratatui(&spec);
395        assert_eq!(chart.title(), "Empty");
396        assert!(chart.x_labels.is_empty());
397        assert_eq!(chart.x_bounds, [0.0, 1.0]);
398    }
399}