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

            
13
use ratatui::Frame;
14
use ratatui::layout::{Constraint, Direction, Layout, Rect};
15
use ratatui::style::{Color, Style};
16
use ratatui::symbols;
17
use ratatui::text::Line;
18
use ratatui::widgets::{Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, Paragraph};
19

            
20
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
21

            
22
/// Palette cycled through series. Terminal-friendly colours that read
23
/// against both light and dark backgrounds.
24
const 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

            
34
10
fn color_for(index: usize) -> Color {
35
10
    PALETTE[index % PALETTE.len()]
36
10
}
37

            
38
/// One series materialised for ratatui: label, palette colour, and
39
/// the `(x_index, y)` points that `Dataset` / `BarChart` borrow.
40
struct 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.
49
pub 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

            
61
impl 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
4
    pub fn title(&self) -> &str {
187
4
        &self.title
188
4
    }
189

            
190
    #[must_use]
191
3
    pub fn notes(&self) -> &[String] {
192
3
        &self.notes
193
3
    }
194
}
195

            
196
#[must_use]
197
6
pub fn render_ratatui(spec: &ChartSpec) -> RatatuiChart {
198
    // x-labels: union in insertion order.
199
6
    let mut x_labels: Vec<String> = Vec::new();
200
10
    for series in &spec.series {
201
19
        for point in &series.points {
202
19
            if !x_labels.iter().any(|l| l == &point.x) {
203
10
                x_labels.push(point.x.clone());
204
10
            }
205
        }
206
    }
207

            
208
    // Per-series f64 dataset, x = 0-based index into x_labels.
209
6
    let series: Vec<RenderedSeries> = spec
210
6
        .series
211
6
        .iter()
212
6
        .enumerate()
213
10
        .map(|(s_idx, s)| {
214
10
            let points: Vec<(f64, f64)> = x_labels
215
10
                .iter()
216
10
                .enumerate()
217
20
                .map(|(x_idx, label)| {
218
20
                    let y = s
219
20
                        .points
220
20
                        .iter()
221
29
                        .find(|p| &p.x == label)
222
20
                        .map_or(0.0, SeriesPoint::y_f64);
223
20
                    (x_idx as f64, y)
224
20
                })
225
10
                .collect();
226
10
            RenderedSeries {
227
10
                label: s.label.clone(),
228
10
                color: color_for(s_idx),
229
10
                points,
230
10
            }
231
10
        })
232
6
        .collect();
233

            
234
6
    let x_bounds = if x_labels.is_empty() {
235
1
        [0.0, 1.0]
236
    } else {
237
5
        [0.0, (x_labels.len() - 1).max(1) as f64]
238
    };
239

            
240
6
    let (y_min, y_max) = y_bounds(spec);
241
6
    let y_bounds = [y_min, y_max];
242

            
243
6
    RatatuiChart {
244
6
        title: spec.title.clone(),
245
6
        kind: spec.kind,
246
6
        x_label: spec.x_label.clone(),
247
6
        y_label: spec.y_label.clone(),
248
6
        x_labels,
249
6
        notes: spec.notes.clone(),
250
6
        series,
251
6
        x_bounds,
252
6
        y_bounds,
253
6
    }
254
6
}
255

            
256
6
fn y_bounds(spec: &ChartSpec) -> (f64, f64) {
257
6
    let (mut min, mut max) = (0.0_f64, 0.0_f64);
258
10
    for series in &spec.series {
259
19
        for point in &series.points {
260
19
            let y = point.y_f64();
261
19
            if y < min {
262
                min = y;
263
19
            }
264
19
            if y > max {
265
11
                max = y;
266
11
            }
267
        }
268
    }
269
6
    let span = (max - min).abs().max(1.0);
270
6
    let pad = span * 0.05;
271
6
    (min - pad, max + pad)
272
6
}
273

            
274
#[cfg(test)]
275
mod tests {
276
    use super::*;
277
    use crate::spec::{ChartKind, Series, SeriesPoint};
278

            
279
4
    fn sample(kind: ChartKind) -> ChartSpec {
280
4
        ChartSpec {
281
4
            title: "Test".to_string(),
282
4
            kind,
283
4
            x_label: "Period".to_string(),
284
4
            y_label: "Amount".to_string(),
285
4
            series: vec![
286
4
                Series {
287
4
                    label: "Income".to_string(),
288
4
                    commodity_symbol: "USD".to_string(),
289
4
                    points: vec![
290
4
                        SeriesPoint {
291
4
                            x: "Jan".to_string(),
292
4
                            y_num: 100,
293
4
                            y_denom: 1,
294
4
                        },
295
4
                        SeriesPoint {
296
4
                            x: "Feb".to_string(),
297
4
                            y_num: 200,
298
4
                            y_denom: 1,
299
4
                        },
300
4
                    ],
301
4
                },
302
4
                Series {
303
4
                    label: "Expense".to_string(),
304
4
                    commodity_symbol: "USD".to_string(),
305
4
                    points: vec![
306
4
                        SeriesPoint {
307
4
                            x: "Jan".to_string(),
308
4
                            y_num: 80,
309
4
                            y_denom: 1,
310
4
                        },
311
4
                        SeriesPoint {
312
4
                            x: "Feb".to_string(),
313
4
                            y_num: 150,
314
4
                            y_denom: 1,
315
4
                        },
316
4
                    ],
317
4
                },
318
4
            ],
319
4
            notes: vec!["Showing USD only.".to_string()],
320
4
        }
321
4
    }
322

            
323
    #[test]
324
1
    fn render_ratatui_builds_for_each_kind() {
325
3
        for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
326
3
            let chart = render_ratatui(&sample(kind));
327
3
            assert_eq!(chart.title(), "Test");
328
3
            assert_eq!(chart.notes(), &["Showing USD only.".to_string()]);
329
        }
330
1
    }
331

            
332
    #[test]
333
1
    fn render_ratatui_collects_all_x_labels_in_order() {
334
1
        let chart = render_ratatui(&sample(ChartKind::Line));
335
1
        assert_eq!(chart.x_labels, vec!["Jan".to_string(), "Feb".to_string()]);
336
1
    }
337

            
338
    #[test]
339
1
    fn render_ratatui_builds_series_with_zero_fill() {
340
        // Second series misses one x-label — should fill with zero.
341
1
        let spec = ChartSpec {
342
1
            title: "T".to_string(),
343
1
            kind: ChartKind::Line,
344
1
            x_label: "X".to_string(),
345
1
            y_label: "Y".to_string(),
346
1
            series: vec![
347
1
                Series {
348
1
                    label: "A".to_string(),
349
1
                    commodity_symbol: String::new(),
350
1
                    points: vec![
351
1
                        SeriesPoint {
352
1
                            x: "x1".to_string(),
353
1
                            y_num: 1,
354
1
                            y_denom: 1,
355
1
                        },
356
1
                        SeriesPoint {
357
1
                            x: "x2".to_string(),
358
1
                            y_num: 2,
359
1
                            y_denom: 1,
360
1
                        },
361
1
                    ],
362
1
                },
363
1
                Series {
364
1
                    label: "B".to_string(),
365
1
                    commodity_symbol: String::new(),
366
1
                    points: vec![SeriesPoint {
367
1
                        x: "x2".to_string(),
368
1
                        y_num: 5,
369
1
                        y_denom: 1,
370
1
                    }],
371
1
                },
372
1
            ],
373
1
            notes: vec![],
374
1
        };
375
1
        let chart = render_ratatui(&spec);
376
1
        assert_eq!(chart.series.len(), 2);
377
1
        let b_points = &chart.series[1].points;
378
1
        assert_eq!(b_points.len(), 2);
379
        // B at x1 should be zero-filled.
380
1
        assert_eq!(b_points[0], (0.0, 0.0));
381
1
        assert_eq!(b_points[1], (1.0, 5.0));
382
1
    }
383

            
384
    #[test]
385
1
    fn render_ratatui_empty_spec_is_safe() {
386
1
        let spec = ChartSpec {
387
1
            title: "Empty".to_string(),
388
1
            kind: ChartKind::Bar,
389
1
            x_label: String::new(),
390
1
            y_label: String::new(),
391
1
            series: vec![],
392
1
            notes: vec![],
393
1
        };
394
1
        let chart = render_ratatui(&spec);
395
1
        assert_eq!(chart.title(), "Empty");
396
1
        assert!(chart.x_labels.is_empty());
397
1
        assert_eq!(chart.x_bounds, [0.0, 1.0]);
398
1
    }
399
}