Lines
95.7 %
Functions
40.48 %
Branches
100 %
//! Plain-text chart renderer. Draws `ChartSpec` as a block of text
//! suitable for the CLI's log region, terminal stdout, or any
//! context where SVG/canvas/ratatui widgets aren't available.
//!
//! Bar and stacked-bar specs render as horizontal bars sized to the
//! biggest magnitude. Line specs render as a per-series sparkline.
//! The output is purely monospace text — no colour — so it survives
//! pasting into logs and scrolling.
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
const DEFAULT_WIDTH: usize = 48;
/// Render `spec` to a multi-line string. `width` controls how wide
/// each bar gets (min 10, max ~200 is sensible).
#[must_use]
pub fn render_text(spec: &ChartSpec, width: usize) -> String {
let width = width.clamp(10, 200);
let mut out = String::new();
out.push_str(&spec.title);
out.push('\n');
if !spec.y_label.is_empty() {
out.push_str(&format!(" ({})\n", spec.y_label));
}
match spec.kind {
ChartKind::Bar | ChartKind::StackedBar => draw_bars(spec, width, &mut out),
ChartKind::Line => draw_lines(spec, width, &mut out),
if !spec.notes.is_empty() {
for note in &spec.notes {
out.push_str(" · ");
out.push_str(note);
out
/// Default-width convenience wrapper: 48 columns, readable in the
/// typical terminal log pane.
pub fn render_text_default(spec: &ChartSpec) -> String {
render_text(spec, DEFAULT_WIDTH)
fn draw_bars(spec: &ChartSpec, width: usize, out: &mut String) {
// Flatten every (series label, point) into (label, value) rows so
// each bar gets an independent entry. Stacked vs grouped collapse
// to the same visual in text — one bar per (series, x).
let mut rows: Vec<(String, f64)> = Vec::new();
for series in &spec.series {
let prefix = if spec.series.len() > 1 {
format!("{}:", series.label)
} else {
String::new()
};
for point in &series.points {
let label = if prefix.is_empty() {
point.x.clone()
} else if point.x.is_empty() {
series.label.clone()
format!("{prefix}{}", point.x)
rows.push((label, point.y_f64()));
if rows.is_empty() {
out.push_str(" (no data)\n");
return;
let label_width = rows
.iter()
.map(|(l, _)| l.chars().count())
.max()
.unwrap_or(1)
.min(width / 2);
let max_abs = rows
.map(|(_, v)| v.abs())
.fold(0.0_f64, f64::max)
.max(1.0);
let bar_width = width.saturating_sub(label_width + 3).max(4);
for (label, value) in rows {
let filled = ((value.abs() / max_abs) * bar_width as f64).round() as usize;
let bar: String = "█".repeat(filled.min(bar_width));
let label_trunc: String = truncate_left(&label, label_width);
let sign = if value < 0.0 { "-" } else { " " };
out.push_str(&format!(
" {label_trunc:>label_width$} {sign}{bar} {value:.2}\n",
));
fn draw_lines(spec: &ChartSpec, width: usize, out: &mut String) {
// For each series, draw a one-line sparkline using Unicode block
// characters. Align them by a common min/max so multiple series
// on the same chart read as comparable.
if spec.series.is_empty() || spec.series.iter().all(|s| s.points.is_empty()) {
let (min, max) = spec
.series
.flat_map(|s| s.points.iter().map(SeriesPoint::y_f64))
.fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), y| {
(mn.min(y), mx.max(y))
});
let span = (max - min).abs().max(1.0);
let label_width = spec
.map(|s| s.label.chars().count())
let line_width = width.saturating_sub(label_width + 3).max(4);
const BLOCKS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let label = truncate_left(&series.label, label_width);
let sparkline: String = (0..series.points.len().min(line_width))
.map(|idx| {
let y = series.points[idx].y_f64();
let pos = ((y - min) / span * (BLOCKS.len() - 1) as f64)
.round()
.clamp(0.0, (BLOCKS.len() - 1) as f64);
BLOCKS[pos as usize]
})
.collect();
out.push_str(&format!(" {label:>label_width$} {sparkline}\n"));
fn truncate_left(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s.to_string()
// Keep the trailing characters — account names often share a
// common prefix (e.g. "Expenses:Food") and the tail is the
// informative bit.
chars[chars.len() - max..].iter().collect()
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{ChartKind, Series, SeriesPoint};
fn sample_bar() -> ChartSpec {
ChartSpec {
title: "Balance".to_string(),
kind: ChartKind::Bar,
x_label: "Account".to_string(),
y_label: "USD".to_string(),
series: vec![
Series {
label: "Assets".to_string(),
commodity_symbol: "USD".to_string(),
points: vec![SeriesPoint {
x: "Assets".to_string(),
y_num: 10_000,
y_denom: 1,
}],
},
label: "Income".to_string(),
x: "Income".to_string(),
y_num: -3_000,
],
notes: vec!["Top 2 accounts.".to_string()],
#[test]
fn render_text_contains_title_and_labels() {
let out = render_text(&sample_bar(), 40);
assert!(out.contains("Balance"));
assert!(out.contains("Assets"));
assert!(out.contains("Income"));
assert!(out.contains("Top 2 accounts."));
fn render_text_empty_spec_is_safe() {
let spec = ChartSpec {
title: "Empty".to_string(),
x_label: String::new(),
y_label: String::new(),
series: vec![],
notes: vec![],
let out = render_text(&spec, 20);
assert!(out.contains("no data"));
fn render_text_line_uses_sparkline_blocks() {
title: "Line".to_string(),
kind: ChartKind::Line,
x_label: "Month".to_string(),
series: vec![Series {
points: vec![
SeriesPoint {
x: "Jan".to_string(),
y_num: 100,
x: "Feb".to_string(),
y_num: 200,
x: "Mar".to_string(),
y_num: 50,
let out = render_text(&spec, 30);
// At least one sparkline block char appears in the output.
assert!(out.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)));
fn truncate_left_keeps_tail() {
assert_eq!(truncate_left("Expenses:Food", 5), ":Food");
assert_eq!(truncate_left("Food", 10), "Food");