use std::collections::VecDeque;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Sparkline}
};
use rust_i18n::t;
use crate::tui::{app::App, themes::Palette};
const BASELINE_SAMPLES: usize = 8;
struct MetricRow {
label: String,
value: String,
value_color: Color,
data: Vec<u64>,
max: u64,
spark_color: Color
}
pub struct StatsWidget {
enabled: bool
}
impl StatsWidget {
#[must_use]
pub const fn new(enabled: bool) -> Self {
Self {
enabled
}
}
fn level_color(value: f64, palette: Palette) -> Color {
if value < 60.0 {
palette.success
} else if value < 85.0 {
palette.warning
} else {
palette.error
}
}
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
const fn pct_to_u64(value: f64) -> u64 {
value.clamp(0.0, 100.0).round() as u64
}
fn bytes_to_mb(bytes: f64) -> f64 {
bytes / 1_048_576.0
}
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
const fn bytes_to_spark(value: f64) -> u64 {
value.max(0.0).round() as u64
}
fn percent_row(
label: impl Into<String>,
history: &VecDeque<f64>,
palette: Palette
) -> MetricRow {
let label = label.into();
history.back().map_or_else(
|| MetricRow {
label: label.clone(),
value: "\u{2014}".to_owned(),
value_color: palette.dim,
data: vec![0; BASELINE_SAMPLES],
max: 100,
spark_color: palette.dim
},
|&last| {
let color = Self::level_color(last, palette);
MetricRow {
label: label.clone(),
value: format!("{}%", Self::pct_to_u64(last)),
value_color: color,
data: history.iter().map(|&v| Self::pct_to_u64(v)).collect(),
max: 100,
spark_color: color
}
}
)
}
fn net_row(
label: impl Into<String>,
history: &VecDeque<f64>,
color: Color,
palette: Palette
) -> MetricRow {
let label = label.into();
history.back().map_or_else(
|| MetricRow {
label: label.clone(),
value: "\u{2014}".to_owned(),
value_color: palette.dim,
data: vec![0; BASELINE_SAMPLES],
max: 1,
spark_color: palette.dim
},
|&last| {
let data: Vec<u64> = history.iter().map(|&v| Self::bytes_to_spark(v)).collect();
let max = data.iter().copied().max().unwrap_or(1).max(1);
MetricRow {
label: label.clone(),
value: format!("{:.1}M", Self::bytes_to_mb(last)),
value_color: color,
data,
max,
spark_color: color
}
}
)
}
fn metric_rows(app: &App, palette: Palette) -> Vec<MetricRow> {
let mut rows = Vec::with_capacity(4);
if !app.cpu_history.is_empty() {
rows.push(Self::percent_row(
t!("stats.cpu").to_string(),
&app.cpu_history,
palette
));
}
if !app.ram_history.is_empty() {
rows.push(Self::percent_row(
t!("stats.ram").to_string(),
&app.ram_history,
palette
));
}
if !app.net_in_history.is_empty() {
rows.push(Self::net_row(
t!("stats.net_in").to_string(),
&app.net_in_history,
palette.accent,
palette
));
}
if !app.net_out_history.is_empty() {
rows.push(Self::net_row(
t!("stats.net_out").to_string(),
&app.net_out_history,
palette.warning,
palette
));
}
rows
}
}
impl crate::tui::widgets::Widget for StatsWidget {
fn id(&self) -> &'static str {
"stats"
}
fn name(&self) -> &'static str {
"Stats"
}
fn enabled(&self) -> bool {
self.enabled
}
fn toggle(&mut self) {
self.enabled = !self.enabled;
}
fn render(&self, frame: &mut Frame, area: Rect, app: &App) {
let palette = app.theme.palette();
let title = app.stats_subject.as_ref().map_or_else(
|| format!(" {} ", t!("stats.title")),
|name| format!(" {} — {name} ", t!("stats.title"))
);
let border_color = if app.focus != crate::tui::app::Focus::Stats {
palette.border
} else if app.focus_active {
palette.success
} else {
palette.accent
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Line::from(Span::styled(
title,
Style::default()
.fg(palette.title)
.add_modifier(Modifier::BOLD)
)));
let inner = block.inner(area);
frame.render_widget(block, area);
if app.stats_subject.is_none() {
let hint = Paragraph::new(t!("stats.subject_hint").to_string())
.style(Style::default().fg(palette.dim))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(hint, inner);
return;
}
let rows = Self::metric_rows(app, palette);
if rows.is_empty() {
return;
}
let row_count = u16::try_from(rows.len()).unwrap_or(u16::MAX);
let content_height = row_count.saturating_mul(2).saturating_sub(1);
let pad = inner.height.saturating_sub(content_height) / 2;
let content = Rect {
x: inner.x,
y: inner.y.saturating_add(pad),
width: inner.width,
height: content_height.min(inner.height)
};
let constraints: Vec<Constraint> = rows.iter().map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(constraints).spacing(1).split(content);
for (row, &row_area) in rows.iter().zip(row_areas.iter()) {
let cols =
Layout::horizontal([Constraint::Length(11), Constraint::Min(0)]).split(row_area);
let label = Paragraph::new(Line::from(vec![
Span::styled(
format!("{} ", row.label),
Style::default().fg(palette.header)
),
Span::styled(
row.value.clone(),
Style::default()
.fg(row.value_color)
.add_modifier(Modifier::BOLD)
),
]));
frame.render_widget(label, cols[0]);
let sparkline = Sparkline::default()
.data(row.data.clone())
.max(row.max)
.style(Style::default().fg(row.spark_color));
frame.render_widget(sparkline, cols[1]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::themes::Theme;
fn palette() -> Palette {
Theme::default().palette()
}
#[test]
fn level_color_low_is_success() {
let p = palette();
assert_eq!(StatsWidget::level_color(0.0, p), p.success);
assert_eq!(StatsWidget::level_color(59.9, p), p.success);
}
#[test]
fn level_color_mid_is_warning() {
let p = palette();
assert_eq!(StatsWidget::level_color(60.0, p), p.warning);
assert_eq!(StatsWidget::level_color(84.9, p), p.warning);
}
#[test]
fn level_color_high_is_error() {
let p = palette();
assert_eq!(StatsWidget::level_color(85.0, p), p.error);
assert_eq!(StatsWidget::level_color(100.0, p), p.error);
}
#[test]
fn pct_to_u64_clamps_and_rounds() {
assert_eq!(StatsWidget::pct_to_u64(-5.0), 0);
assert_eq!(StatsWidget::pct_to_u64(0.4), 0);
assert_eq!(StatsWidget::pct_to_u64(0.5), 1);
assert_eq!(StatsWidget::pct_to_u64(73.6), 74);
assert_eq!(StatsWidget::pct_to_u64(150.0), 100);
}
#[test]
fn bytes_to_mb_converts_mebibytes() {
assert!((StatsWidget::bytes_to_mb(1_048_576.0) - 1.0).abs() < f64::EPSILON);
assert!((StatsWidget::bytes_to_mb(0.0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn percent_row_empty_shows_dim_dash_baseline() {
let p = palette();
let history = VecDeque::new();
let row = StatsWidget::percent_row("CPU", &history, p);
assert_eq!(row.value, "\u{2014}");
assert_eq!(row.value_color, p.dim);
assert_eq!(row.data.len(), BASELINE_SAMPLES);
assert!(row.data.iter().all(|&v| v == 0));
assert_eq!(row.max, 100);
}
#[test]
fn percent_row_with_data_uses_level_color() {
let p = palette();
let history: VecDeque<f64> = [10.0, 50.0, 90.0].into_iter().collect();
let row = StatsWidget::percent_row("CPU", &history, p);
assert_eq!(row.value, "90%");
assert_eq!(row.value_color, p.error);
assert_eq!(row.data, vec![10, 50, 90]);
}
#[test]
fn net_row_empty_shows_dim_dash_baseline() {
let p = palette();
let history = VecDeque::new();
let row = StatsWidget::net_row("Net\u{2193}", &history, p.accent, p);
assert_eq!(row.value, "\u{2014}");
assert_eq!(row.value_color, p.dim);
assert_eq!(row.data.len(), BASELINE_SAMPLES);
assert_eq!(row.max, 1);
}
#[test]
fn net_row_with_data_scales_to_max() {
let p = palette();
let history: VecDeque<f64> = [0.0, 1_048_576.0, 2_097_152.0].into_iter().collect();
let row = StatsWidget::net_row("Net\u{2193}", &history, p.accent, p);
assert_eq!(row.value, "2.0M");
assert_eq!(row.value_color, p.accent);
assert_eq!(row.max, 2_097_152);
}
#[test]
fn metric_rows_returns_a_row_per_populated_series() {
let p = palette();
let mut app = App::new(5);
app.push_cpu(10.0);
app.push_ram(20.0);
app.push_net_in(100.0);
app.push_net_out(200.0);
let rows = StatsWidget::metric_rows(&app, p);
assert_eq!(rows[0].label, "CPU");
assert_eq!(rows[1].label, "RAM");
assert_eq!(rows[2].label, "Net\u{2193}");
assert_eq!(rows[3].label, "Net\u{2191}");
}
#[test]
fn metric_rows_empty_without_data() {
let p = palette();
let app = App::new(5);
assert!(StatsWidget::metric_rows(&app, p).is_empty());
}
}