use std::fmt::Write as _;
use unicode_width::UnicodeWidthStr;
use crate::pie::PieChart;
const DEFAULT_WIDTH: usize = 80;
const MIN_BAR_WIDTH: usize = 10;
const GAP: usize = 2;
const RESET: &str = "\x1b[0m";
const SLICE_PALETTE: &[(u8, u8, u8)] = &[
(86, 180, 233), (230, 97, 0), (0, 158, 115), (204, 121, 167), (240, 228, 66), (0, 114, 178), (213, 94, 0), (0, 178, 128), (153, 79, 204), (255, 164, 0), (128, 177, 211), (251, 128, 114), ];
#[inline]
fn pick_slice_color(idx: usize) -> (u8, u8, u8) {
SLICE_PALETTE[idx % SLICE_PALETTE.len()]
}
pub fn render(chart: &PieChart, max_width: Option<usize>) -> String {
render_inner(chart, max_width, false)
}
pub fn render_color(chart: &PieChart, max_width: Option<usize>) -> String {
render_inner(chart, max_width, true)
}
fn render_inner(chart: &PieChart, max_width: Option<usize>, with_color: bool) -> String {
let budget = max_width.unwrap_or(DEFAULT_WIDTH);
let total = chart.total();
let label_w = chart
.slices
.iter()
.map(|s| UnicodeWidthStr::width(s.label.as_str()))
.max()
.unwrap_or(0);
let pct_w = 6; let value_strs: Vec<String> = if chart.show_data {
chart
.slices
.iter()
.map(|s| format!("({})", format_value(s.value)))
.collect()
} else {
Vec::new()
};
let val_w = value_strs.iter().map(|s| s.len()).max().unwrap_or(0);
let chrome = label_w + pct_w + GAP * 2 + if val_w > 0 { val_w + GAP } else { 0 };
let bar_w = budget.saturating_sub(chrome).max(MIN_BAR_WIDTH);
let row_w = chrome + bar_w;
let mut out = String::new();
if let Some(title) = chart.title.as_deref() {
let tw = UnicodeWidthStr::width(title);
let pad = row_w.saturating_sub(tw) / 2;
out.push_str(&" ".repeat(pad));
out.push_str(title);
out.push('\n');
out.push('\n');
}
for (i, slice) in chart.slices.iter().enumerate() {
let share = if total > 0.0 {
slice.value / total
} else {
0.0
};
let filled = (share * bar_w as f64).round() as usize;
let filled = filled.min(bar_w);
let unfilled = bar_w - filled;
let lw = UnicodeWidthStr::width(slice.label.as_str());
out.push_str(&slice.label);
out.push_str(&" ".repeat(label_w.saturating_sub(lw)));
out.push_str(&" ".repeat(GAP));
if with_color {
let (r, g, b) = pick_slice_color(i);
let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
out.push_str(&"█".repeat(filled));
out.push_str(RESET);
} else {
out.push_str(&"█".repeat(filled));
}
out.push_str(&"░".repeat(unfilled));
out.push_str(&" ".repeat(GAP));
out.push_str(&format!("{:>5.1}%", share * 100.0));
if chart.show_data {
out.push_str(&" ".repeat(GAP));
let v = &value_strs[i];
out.push_str(&" ".repeat(val_w.saturating_sub(v.len())));
out.push_str(v);
}
out.push('\n');
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn format_value(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
let mut s = format!("{v:.6}");
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.push('0');
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::pie::parse;
#[test]
fn renders_minimal() {
let c = parse("pie\n\"A\" : 1\n\"B\" : 1").unwrap();
let out = render(&c, Some(60));
assert!(out.contains('█'));
assert!(out.contains("50.0%"));
}
#[test]
fn renders_title_centred() {
let c = parse("pie title Pets\n\"A\" : 1").unwrap();
let out = render(&c, Some(60));
assert!(out.contains("Pets"));
}
#[test]
fn show_data_appends_raw_value() {
let c = parse("pie showData\n\"A\" : 386").unwrap();
let out = render(&c, Some(80));
assert!(out.contains("(386)"));
}
#[test]
fn show_data_off_omits_raw_value() {
let c = parse("pie\n\"A\" : 386").unwrap();
let out = render(&c, Some(80));
assert!(!out.contains("(386)"));
}
#[test]
fn format_value_integers_drop_decimal() {
assert_eq!(format_value(386.0), "386");
assert_eq!(format_value(0.5), "0.5");
assert_eq!(format_value(1.25), "1.25");
}
#[test]
fn narrow_terminal_clamps_to_min_bar_width() {
let c = parse("pie\n\"A\" : 1\n\"B\" : 1").unwrap();
let out = render(&c, Some(20));
let bar_count = out.chars().filter(|&c| c == '█' || c == '░').count();
assert!(bar_count >= MIN_BAR_WIDTH * c.slices.len());
}
#[test]
fn render_color_emits_ansi_escapes() {
let c = parse("pie\n\"A\" : 1\n\"B\" : 1\n\"C\" : 2").unwrap();
let out = render_color(&c, Some(80));
assert!(out.contains("\x1b[38;2;"), "expected ANSI escape: {out:?}");
assert!(out.contains("\x1b[0m"), "expected ANSI reset: {out:?}");
assert!(out.contains("50.0%"));
}
#[test]
fn render_monochrome_has_no_ansi() {
let c = parse("pie\n\"A\" : 1\n\"B\" : 2").unwrap();
let out = render(&c, Some(80));
assert!(
!out.contains('\x1b'),
"unexpected ANSI escape in monochrome output"
);
}
#[test]
fn palette_cycles_for_many_slices() {
let mut src = String::from("pie");
for i in 0..14 {
src.push_str(&format!("\n\"Slice{i}\" : 1"));
}
let c = parse(&src).unwrap();
let out = render_color(&c, Some(120));
assert!(out.contains("\x1b[38;2;"));
}
}