Skip to main content

open_eeg_codec_standard/
term.rs

1//! Terminal rendering — colored, boxed, chart-y read-outs (host-side DX).
2//!
3//! This is the "nice read-out on the terminal" layer. It consumes the plain
4//! data types from [`crate::report`] / [`crate::harness`] and renders them
5//! with color (via the `console` crate, which auto-detects a tty and honours
6//! `NO_COLOR`), unicode frames, grade badges, sparklines, and bar charts. The
7//! data modules stay free of presentation concerns; all of that lives here.
8//!
9//! Every renderer takes an explicit `color: bool` so output is deterministic
10//! and testable (pass `false` for plain text). [`colors_on`] is the sensible
11//! default a CLI computes once.
12
13use console::{measure_text_width, style};
14
15use crate::harness::CorpusSummary;
16use crate::report::EcsReport;
17
18/// Sparkline block ramp, lowest → highest.
19const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
20
21/// Whether color should be used by default on this host (tty + not `NO_COLOR`).
22pub fn colors_on() -> bool {
23    console::colors_enabled()
24}
25
26/// Colorize `s` with a `console` style closure when `color`, else return it
27/// plain. `force_styling(true)` makes the `color` flag authoritative — the
28/// caller (the CLI) already decided based on the tty / `NO_COLOR`, so we must
29/// not let `console`'s own auto-detection override an explicit `true` (it
30/// would suppress ANSI when stdout is not a tty, e.g. under `cargo test`).
31fn paint(s: &str, color: bool, f: impl Fn(console::StyledObject<&str>) -> console::StyledObject<&str>) -> String {
32    if color {
33        f(style(s).force_styling(true)).to_string()
34    } else {
35        s.to_string()
36    }
37}
38
39/// The display color for a tier grade (used for badges + leaderboard rows).
40fn grade_paint(grade: char, color: bool, text: &str) -> String {
41    if !color {
42        return text.to_string();
43    }
44    let st = style(text).force_styling(true).bold();
45    match grade {
46        'L' => st.green(),
47        'N' => st.green().bright(),
48        'C' => st.cyan(),
49        'M' => st.yellow(),
50        'A' => st.magenta(),
51        _ => st.red(),
52    }
53    .to_string()
54}
55
56/// A one-token grade badge, e.g. `ECS-L` / `— below floor`, colored by tier.
57pub fn grade_badge(grade: char, color: bool) -> String {
58    let label = match grade {
59        '\0' => "— below floor".to_string(),
60        g => format!("ECS-{g}"),
61    };
62    grade_paint(grade, color, &label)
63}
64
65/// A compact, fixed-width-friendly grade label (`ECS-L` / `—`) colored by
66/// tier — for table cells where `— below floor` would break the column.
67pub fn grade_short(grade: char, color: bool) -> String {
68    let label = if grade == '\0' {
69        "—".to_string()
70    } else {
71        format!("ECS-{grade}")
72    };
73    grade_paint(grade, color, &label)
74}
75
76/// A unicode sparkline of `vals` mapped onto their own min–max range.
77///
78/// A flat series renders as a mid-level line. Non-finite values are treated
79/// as the series minimum so the call never panics.
80pub fn sparkline(vals: &[f64]) -> String {
81    if vals.is_empty() {
82        return String::new();
83    }
84    let finite: Vec<f64> = vals.iter().map(|v| if v.is_finite() { *v } else { f64::NEG_INFINITY }).collect();
85    let lo = finite.iter().cloned().filter(|v| v.is_finite()).fold(f64::INFINITY, f64::min);
86    let hi = finite.iter().cloned().filter(|v| v.is_finite()).fold(f64::NEG_INFINITY, f64::max);
87    if !lo.is_finite() || !hi.is_finite() {
88        return BLOCKS[0].to_string().repeat(vals.len());
89    }
90    let span = hi - lo;
91    finite
92        .iter()
93        .map(|&v| {
94            if span <= 0.0 || !v.is_finite() {
95                BLOCKS[BLOCKS.len() / 2]
96            } else {
97                let idx = (((v - lo) / span) * (BLOCKS.len() - 1) as f64).round() as usize;
98                BLOCKS[idx.min(BLOCKS.len() - 1)]
99            }
100        })
101        .collect()
102}
103
104/// A horizontal bar for `frac` ∈ [0,1] of the given character width.
105pub fn bar(frac: f64, width: usize) -> String {
106    let frac = frac.clamp(0.0, 1.0);
107    let filled = (frac * width as f64).round() as usize;
108    let filled = filled.min(width);
109    let mut s = String::with_capacity(width * 3);
110    for _ in 0..filled {
111        s.push('█');
112    }
113    for _ in filled..width {
114        s.push('░');
115    }
116    s
117}
118
119/// Frame `body` lines in a rounded unicode box under `title`. Padding uses
120/// `console::measure_text_width` so embedded ANSI color codes don't skew the
121/// alignment.
122pub fn boxed(title: &str, body: &[String], color: bool) -> String {
123    let title_w = measure_text_width(title);
124    let content_w = body.iter().map(|l| measure_text_width(l)).max().unwrap_or(0);
125    let inner = content_w.max(title_w).max(20);
126
127    let titled = paint(title, color, |s| s.bold());
128    let mut out = String::new();
129    // Top border carries the title: ╭─ title ──…─╮
130    out.push_str("╭─ ");
131    out.push_str(&titled);
132    out.push(' ');
133    let used = 3 + title_w + 1;
134    let total = inner + 4; // "╭─ " (3) + content + trailing pad to align
135    for _ in used..total.max(used) {
136        out.push('─');
137    }
138    out.push_str("╮\n");
139    for line in body {
140        let pad = inner - measure_text_width(line);
141        out.push_str("│ ");
142        out.push_str(line);
143        for _ in 0..pad {
144            out.push(' ');
145        }
146        out.push_str(" │\n");
147    }
148    out.push('╰');
149    for _ in 0..inner + 2 {
150        out.push('─');
151    }
152    out.push('╯');
153    out
154}
155
156/// Render one report as a colored, boxed read-out with a per-band R sparkline.
157pub fn render_report(rep: &EcsReport, color: bool) -> String {
158    let badge = grade_badge(rep.grade, color);
159    let compliant = if rep.passed() {
160        paint("COMPLIANT", color, |s| s.green().bold())
161    } else {
162        paint("NON-COMPLIANT", color, |s| s.red().bold())
163    };
164    let dim = |s: &str| paint(s, color, |x| x.dim());
165
166    let mut body = Vec::new();
167    body.push(format!("{}   {}   {}", dim("grade"), badge, compliant));
168    body.push(format!(
169        "{}  {:>9.2}:1    {}  {:>7.3}%    {}  {:>7.4}",
170        dim("CR  "),
171        rep.cr,
172        dim("PRD"),
173        rep.prd,
174        dim("R"),
175        rep.r
176    ));
177    body.push(format!(
178        "{}  {:>7.2} dB   {}  {:>9.2} MiB/s   {}  {:>6.2} MiB",
179        dim("SNR "),
180        rep.snr_db,
181        dim("thrpt"),
182        rep.throughput_mibs,
183        dim("peak"),
184        rep.peak_bytes as f64 / (1024.0 * 1024.0),
185    ));
186    // Per-band R sparkline (named bands only, in canonical order).
187    let band_r: Vec<f64> = rep.per_band.iter().map(|b| b.r).collect();
188    if !band_r.is_empty() {
189        let names: String = rep
190            .per_band
191            .iter()
192            .map(|b| b.band.chars().next().unwrap_or('?'))
193            .collect();
194        body.push(format!("{}  {}  {}", dim("band R"), sparkline(&band_r), dim(&names)));
195    }
196    if !rep.violations.is_empty() {
197        body.push(dim("to climb a tier:"));
198        for v in &rep.violations {
199            body.push(format!("  {} {}", paint("·", color, |s| s.red()), v));
200        }
201    }
202
203    let title = format!(
204        "OpenECS report · {} · {} ({} file{})",
205        rep.codec,
206        rep.dataset,
207        rep.n_files,
208        if rep.n_files == 1 { "" } else { "s" }
209    );
210    boxed(&title, &body, color)
211}
212
213/// Render a ranked leaderboard table (grade-first ordering already applied by
214/// the caller via [`crate::report::leaderboard`] data, but this colors it).
215pub fn render_leaderboard(reports: &[&EcsReport], color: bool) -> String {
216    let dim = |s: &str| paint(s, color, |x| x.dim());
217    let mut s = String::new();
218    s.push_str(&paint("OpenECS leaderboard (best first)\n", color, |x| x.bold()));
219    s.push_str(&dim("  #  codec                  grade      CR     PRD%       R       QS\n"));
220    s.push_str(&dim("  ─────────────────────────────────────────────────────────────────\n"));
221    if reports.is_empty() {
222        s.push_str("  (no codecs)\n");
223        return s;
224    }
225    for (i, r) in reports.iter().enumerate() {
226        let g = if r.grade == '\0' {
227            grade_paint('\0', color, "  —  ")
228        } else {
229            grade_paint(r.grade, color, &format!("ECS-{}", r.grade))
230        };
231        s.push_str(&format!(
232            "  {:>2} {:<22} {:>6} {:>7.2} {:>7.3} {:>8.4} {:>8.3}\n",
233            i + 1,
234            truncate(&r.codec, 22),
235            g,
236            r.cr,
237            r.prd,
238            r.r,
239            r.qs,
240        ));
241    }
242    s
243}
244
245/// Render the corpus roll-up line block.
246pub fn render_corpus_summary(summary: &CorpusSummary, color: bool) -> String {
247    let dim = |s: &str| paint(s, color, |x| x.dim());
248    let worst = grade_badge(summary.worst_grade, color);
249    format!(
250        "{}  {}\n{}  {:.2}:1   {}  {:.4}   {}  {:.3}%   {}  {}",
251        dim("worst grade"),
252        worst,
253        dim("pooled CR"),
254        summary.mean_cr,
255        dim("mean R"),
256        summary.mean_r,
257        dim("mean PRD"),
258        summary.mean_prd,
259        dim("bit-exact"),
260        summary.all_bit_exact,
261    )
262}
263
264/// Truncate a string to `n` display columns (used for table cells).
265fn truncate(s: &str, n: usize) -> String {
266    if s.chars().count() <= n {
267        s.to_string()
268    } else {
269        let mut t: String = s.chars().take(n.saturating_sub(1)).collect();
270        t.push('…');
271        t
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::report::BandResult;
279
280    fn rep() -> EcsReport {
281        EcsReport {
282            spec_version: "1.0".into(),
283            codec: "store".into(),
284            dataset: "ecs-smoke".into(),
285            n_files: 3,
286            bit_exact: true,
287            grade: 'L',
288            cr: 24.57,
289            prd: 0.0,
290            prdn: 0.0,
291            r: 1.0,
292            snr_db: 120.0,
293            qs: 24.57,
294            per_band: vec![
295                BandResult::new("delta", 1.0, 0.0, 120.0),
296                BandResult::new("gamma", 0.9, 5.0, 30.0),
297            ],
298            throughput_mibs: 88.5,
299            peak_bytes: 2 * 1024 * 1024,
300            violations: vec![],
301        }
302    }
303
304    #[test]
305    fn sparkline_maps_range() {
306        let s = sparkline(&[0.0, 1.0, 2.0, 3.0]);
307        assert_eq!(s.chars().count(), 4);
308        assert_eq!(s.chars().next(), Some('▁'));
309        assert_eq!(s.chars().last(), Some('█'));
310        // Flat series -> mid blocks, no panic.
311        assert_eq!(sparkline(&[5.0, 5.0]).chars().count(), 2);
312        assert!(sparkline(&[]).is_empty());
313    }
314
315    #[test]
316    fn bar_fills_proportionally() {
317        assert_eq!(bar(0.0, 4), "░░░░");
318        assert_eq!(bar(1.0, 4), "████");
319        assert_eq!(bar(0.5, 4), "██░░");
320        assert_eq!(bar(2.0, 4), "████"); // clamped
321    }
322
323    #[test]
324    fn plain_report_has_no_ansi() {
325        let out = render_report(&rep(), false);
326        assert!(!out.contains('\u{1b}'), "color=false must emit no ANSI escapes");
327        assert!(out.contains("ECS-L"));
328        assert!(out.contains("store"));
329        assert!(out.contains("ecs-smoke"));
330        // Boxed frame present.
331        assert!(out.contains('╭') && out.contains('╰'));
332    }
333
334    #[test]
335    fn colored_report_has_ansi() {
336        let out = render_report(&rep(), true);
337        assert!(out.contains('\u{1b}'), "color=true should emit ANSI escapes");
338    }
339
340    #[test]
341    fn leaderboard_plain_lists_rows() {
342        let r = rep();
343        let out = render_leaderboard(&[&r], false);
344        assert!(out.contains("leaderboard"));
345        assert!(out.contains("store"));
346        assert!(!out.contains('\u{1b}'));
347        assert!(render_leaderboard(&[], false).contains("(no codecs)"));
348    }
349}