use crate::cli::charts::{HealthStrip, Sparkline};
use crate::cli::output::{RunRecord, SeriesHook};
use crate::cli::theme::{ACCENT, SECONDARY};
use crate::cli::tui::fit;
use ansi_to_tui::IntoText;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use scope::market::series::{Metric, MetricId, Unit};
pub(crate) const SIDE_BY_SIDE_MIN_WIDTH: u16 = 80;
const CHART_ROWS: u16 = 2;
const HEALTH_ROWS: u16 = 2;
const LABEL_COLS: usize = 18;
pub(crate) const LEAVE_HINT: &str = "Press any key to leave the view (the key still counts).";
#[derive(Clone, Debug)]
pub(crate) struct Ended {
pub text: String,
pub style: Style,
}
#[derive(Clone)]
pub(crate) struct RepeatView {
pub series: SeriesHook,
pub ended: Option<Ended>,
pub pending: String,
}
pub(crate) fn has_runs(series: &SeriesHook) -> bool {
!lock(series).runs.is_empty()
}
fn lock(series: &SeriesHook) -> std::sync::MutexGuard<'_, crate::cli::output::Series> {
series.lock().unwrap_or_else(|e| e.into_inner())
}
struct Copy {
metrics: Vec<Metric>,
runs: Vec<RunRecord>,
dropped: u64,
}
fn copy(series: &SeriesHook) -> Copy {
let s = lock(series);
Copy {
metrics: s.metrics.clone(),
runs: s.runs.iter().cloned().collect(),
dropped: s.dropped,
}
}
pub(crate) fn final_snapshot(series: &SeriesHook) -> Option<String> {
lock(series).runs.back().map(|r| r.snapshot.clone())
}
pub(crate) fn series_csv(series: &SeriesHook) -> Option<String> {
let c = copy(series);
let columns = scope::market::series::csv_columns(&c.metrics).ok()?;
let mut text = scope::market::series::csv_header(&columns);
for r in &c.runs {
let time = r.at.format("%Y-%m-%dT%H:%M:%SZ").to_string();
text.push_str(&scope::market::series::csv_row(
&columns, &c.metrics, &time, r.run, &r.sample,
));
}
Some(text)
}
pub(crate) fn summary(series: &SeriesHook) -> String {
let c = copy(series);
let total = c.dropped + c.runs.len() as u64;
let failed: Vec<u64> = c
.runs
.iter()
.filter(|r| !r.sample.healthy)
.map(|r| r.run)
.collect();
let runs = if total == 1 { "run" } else { "runs" };
let scope = if c.dropped > 0 {
format!(" in the last {}", c.runs.len())
} else {
String::new()
};
match failed.last() {
None => format!("{total} {runs} · none failed{scope}"),
Some(last) => format!(
"{total} {runs} · {} failed{scope} (run {last})",
failed.len()
),
}
}
fn format_value(metric: &Metric, value: Option<f64>) -> String {
let Some(v) = value else {
return "n/a".to_string();
};
match metric.unit {
Unit::Price => format!("{v:.6}"),
Unit::Percent => format!("{v:.3}%"),
Unit::Quote => scope::display::format::format_large_number(v),
Unit::Flag => if v != 0.0 { "pass" } else { "fail" }.to_string(),
}
}
fn charted(id: MetricId) -> bool {
!matches!(
id,
MetricId::BestBid | MetricId::BestAsk | MetricId::Spread | MetricId::Healthy
)
}
pub(crate) fn draw(f: &mut Frame, area: Rect, view: &RepeatView, status: Vec<Line<'static>>) {
let c = copy(&view.series);
let mut status = status;
if let Some(ended) = &view.ended {
status.push(Line::styled(ended.text.clone(), ended.style));
status.push(Line::styled(LEAVE_HINT, SECONDARY));
}
let status_rows = (status.len() as u16).min(area.height);
let [body, foot] =
Layout::vertical([Constraint::Min(0), Constraint::Length(status_rows)]).areas(area);
f.render_widget(Paragraph::new(Text::from(status)), foot);
let (snap, trends) = if body.width >= SIDE_BY_SIDE_MIN_WIDTH {
let [l, r] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(body);
(l, r)
} else {
let [t, b] =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(body);
(t, b)
};
draw_snapshot(f, snap, c.runs.last());
draw_trends(f, trends, &c);
}
fn draw_snapshot(f: &mut Frame, area: Rect, last: Option<&RunRecord>) {
let Some(last) = last else {
return;
};
let text = last
.snapshot
.as_str()
.into_text()
.unwrap_or_else(|_| Text::raw(last.snapshot.clone()));
let rows = fit::fit(text.lines, area.width as usize);
f.render_widget(Paragraph::new(Text::from(rows)), area);
}
fn draw_trends(f: &mut Frame, area: Rect, c: &Copy) {
let run = c.runs.last().map_or(0, |r| r.run);
let block = Block::new()
.borders(Borders::LEFT | Borders::TOP)
.border_style(SECONDARY)
.title(Line::styled(format!(" Trends · run {run} "), ACCENT));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height < HEALTH_ROWS || inner.width <= LABEL_COLS as u16 {
return;
}
let label = |text: &str| Span::styled(format!("{text:<LABEL_COLS$}"), SECONDARY);
let chart_x = inner.x + LABEL_COLS as u16;
let chart_w = inner.width - LABEL_COLS as u16;
let healthy: Vec<bool> = c.runs.iter().map(|r| r.sample.healthy).collect();
f.render_widget(
Paragraph::new(Line::from(label("Health"))),
Rect::new(inner.x, inner.y, LABEL_COLS as u16, 1),
);
f.render_widget(
HealthStrip::new(&healthy),
Rect::new(chart_x, inner.y, chart_w, 1),
);
let last_failure = c
.runs
.iter()
.rev()
.find(|r| !r.sample.healthy)
.map(|r| {
let why = r
.sample
.failed_checks
.first()
.map_or("unhealthy", String::as_str);
format!("run {}: {why}", r.run)
})
.unwrap_or_else(|| "no failed runs".to_string());
f.render_widget(
Paragraph::new(Line::styled(last_failure, SECONDARY)),
Rect::new(inner.x, inner.y + 1, inner.width, 1),
);
let mut y = inner.y + HEALTH_ROWS;
for (i, metric) in c.metrics.iter().enumerate().filter(|(_, m)| charted(m.id)) {
if y + CHART_ROWS > inner.bottom() {
break;
}
let values: Vec<Option<f64>> = c
.runs
.iter()
.map(|r| r.sample.values.get(i).copied().flatten())
.collect();
let latest = values.last().copied().flatten();
f.render_widget(
Paragraph::new(Line::from(vec![
label(metric.label),
Span::raw(format_value(metric, latest)),
])),
Rect::new(inner.x, y, inner.width, 1),
);
f.render_widget(
Sparkline::new(&values, ACCENT).threshold(metric.threshold),
Rect::new(chart_x, y + 1, chart_w, 1),
);
y += CHART_ROWS;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::output::Output;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use scope::market::HealthThresholds;
use scope::market::series::{Sample, summary_metrics};
fn series(n: u64, failed: &[u64]) -> SeriesHook {
let metrics = summary_metrics(&HealthThresholds::default());
let (out, _cap) = Output::capture_merged();
for run in 1..=n {
let healthy = !failed.contains(&run);
let sample = Sample {
values: metrics
.iter()
.map(|_| Some(1.0 + run as f64 / 100.0))
.collect(),
healthy,
failed_checks: if healthy {
vec![]
} else {
vec![format!("spread 3.{run}%")]
},
};
out.record_run(
&metrics,
RunRecord {
run,
at: chrono::Utc::now(),
sample,
snapshot: format!("Market Summary run {run}\nMid 1.0\n"),
},
);
}
out.series_hook().unwrap()
}
fn render(view: &RepeatView, w: u16, h: u16) -> Vec<String> {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| draw(f, f.area(), view, vec![Line::raw("run 3 of 10")]))
.unwrap();
let buf = t.backend().buffer().clone();
(0..h)
.map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
.collect()
}
fn view(n: u64, failed: &[u64]) -> RepeatView {
RepeatView {
series: series(n, failed),
ended: None,
pending: String::new(),
}
}
#[test]
fn test_wide_view_shows_snapshot_beside_the_trends() {
let rows = render(&view(3, &[2]), 120, 36);
let text = rows.join("\n");
assert!(rows[0].starts_with("Market Summary run 3"), "{text}");
assert!(text.contains("Trends · run 3"), "{text}");
for label in ["Health", "Mid price", "Spread %", "Bid depth"] {
assert!(text.contains(label), "{label}\n{text}");
}
assert!(text.contains("run 2: spread 3.2%"), "{text}");
assert!(text.contains("✓✗✓"), "{text}");
assert!(rows[35].starts_with("run 3 of 10"), "{text}");
}
#[test]
fn test_narrow_view_stacks_and_shows_fewer_charts_without_overlap() {
let wide = render(&view(3, &[]), 120, 36).join("\n");
let narrow_rows = render(&view(3, &[]), 79, 24);
let narrow = narrow_rows.join("\n");
let charts = |t: &str| t.matches("depth").count() + t.matches("Mid price").count();
assert!(charts(&narrow) < charts(&wide), "{narrow}");
assert!(
narrow_rows[0].starts_with("Market Summary run 3"),
"{narrow}"
);
assert!(
narrow_rows
.iter()
.position(|r| r.contains("Trends"))
.unwrap()
> 1
);
for row in narrow_rows.iter().filter(|r| r.contains("depth")) {
assert_eq!(row.matches("depth").count(), 1, "{narrow}");
}
}
#[test]
fn test_a_finished_view_shows_its_footer_and_how_to_leave() {
let mut v = view(2, &[]);
v.ended = Some(Ended {
text: "Cancelled after 4.0s.".into(),
style: Style::new(),
});
let text = render(&v, 120, 36).join("\n");
assert!(text.contains("Cancelled after 4.0s."), "{text}");
assert!(text.contains(LEAVE_HINT), "{text}");
}
#[test]
fn test_summary_counts_runs_and_names_the_last_failure() {
assert_eq!(
summary(&series(42, &[3, 16])),
"42 runs · 2 failed (run 16)"
);
assert_eq!(summary(&series(1, &[])), "1 run · none failed");
}
#[test]
fn test_series_csv_has_the_cli_header_and_one_row_per_run() {
let csv = series_csv(&series(3, &[])).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert!(lines[0].starts_with("timestamp,run,best_bid,"), "{csv}");
assert_eq!(lines.len(), 4);
assert_eq!(lines[3].split(',').nth(1), Some("3"));
}
#[test]
fn test_values_read_in_their_unit() {
let m = summary_metrics(&HealthThresholds::default());
let by = |id| m.iter().find(|x| x.id == id).unwrap();
assert_eq!(format_value(by(MetricId::SpreadPct), Some(0.25)), "0.250%");
assert_eq!(
format_value(by(MetricId::BidDepth), Some(1_500_000.0)),
"1.50M"
);
assert_eq!(format_value(by(MetricId::Mid), None), "n/a");
}
}