use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Sparkline as RatatuiSparkline, Widget},
};
const DEFAULT_ERROR_COLOR: Color = Color::Rgb(239, 68, 68); const DEFAULT_WARN_COLOR: Color = Color::Rgb(245, 158, 11); const DEFAULT_SUCCESS_COLOR: Color = Color::Rgb(34, 197, 94); const DEFAULT_HIGHLIGHT_COLOR: Color = Color::Rgb(99, 102, 241);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SparklineAnimation {
#[default]
None,
Pulse,
Flow,
Wave,
}
pub struct LatencySparkline<'a> {
data: &'a [u64],
title: &'a str,
warn_threshold: u64,
error_threshold: u64,
show_max: bool,
show_avg: bool,
}
impl<'a> LatencySparkline<'a> {
pub fn new(data: &'a [u64]) -> Self {
Self {
data,
title: "",
warn_threshold: 500,
error_threshold: 2000,
show_max: false,
show_avg: false,
}
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
pub fn warn_threshold(mut self, ms: u64) -> Self {
self.warn_threshold = ms;
self
}
pub fn error_threshold(mut self, ms: u64) -> Self {
self.error_threshold = ms;
self
}
pub fn show_max(mut self) -> Self {
self.show_max = true;
self
}
pub fn show_avg(mut self) -> Self {
self.show_avg = true;
self
}
fn get_color(&self) -> Color {
let max = self.data.iter().max().copied().unwrap_or(0);
if max >= self.error_threshold {
DEFAULT_ERROR_COLOR
} else if max >= self.warn_threshold {
DEFAULT_WARN_COLOR
} else {
DEFAULT_SUCCESS_COLOR
}
}
fn average(&self) -> u64 {
if self.data.is_empty() {
return 0;
}
self.data.iter().sum::<u64>() / self.data.len() as u64
}
fn max(&self) -> u64 {
self.data.iter().max().copied().unwrap_or(0)
}
}
impl Widget for LatencySparkline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 5 || area.height < 1 {
return;
}
let color = self.get_color();
let label_width = if self.show_max || self.show_avg {
10u16 } else {
0
};
let sparkline_width = area.width.saturating_sub(label_width);
let sparkline_area = Rect {
x: area.x,
y: area.y,
width: sparkline_width,
height: area.height,
};
let sparkline = RatatuiSparkline::default()
.block(Block::default())
.data(self.data)
.style(Style::default().fg(color));
sparkline.render(sparkline_area, buf);
if self.show_max || self.show_avg {
let label_x = area.x + sparkline_width + 1;
let mut label_y = area.y;
if self.show_max && area.height >= 1 {
let max_str = format_ms(self.max());
let max_label = format!("⬆{}", max_str);
buf.set_string(label_x, label_y, &max_label, Style::default().fg(color));
label_y += 1;
}
if self.show_avg && label_y < area.y + area.height {
let avg_str = format_ms(self.average());
let avg_label = format!("~{}", avg_str);
buf.set_string(
label_x, label_y,
&avg_label,
Style::default().fg(Color::Gray),
);
}
}
}
}
pub struct MiniSparkline<'a> {
data: &'a [u64],
label: &'a str,
color: Color,
}
impl<'a> MiniSparkline<'a> {
pub fn new(data: &'a [u64], label: &'a str) -> Self {
Self {
data,
label,
color: Color::Cyan,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
}
impl Widget for MiniSparkline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 8 || area.height < 1 {
return;
}
let label_len = self.label.len().min(u16::MAX as usize) as u16;
buf.set_string(
area.x,
area.y,
self.label,
Style::default().fg(Color::DarkGray),
);
let sparkline_area = Rect {
x: area.x + label_len + 1,
y: area.y,
width: area.width.saturating_sub(label_len + 1),
height: 1,
};
let sparkline = RatatuiSparkline::default()
.block(Block::default())
.data(self.data)
.style(Style::default().fg(self.color));
sparkline.render(sparkline_area, buf);
}
}
pub struct BorderedSparkline<'a> {
data: &'a [u64],
title: &'a str,
color: Color,
}
impl<'a> BorderedSparkline<'a> {
pub fn new(data: &'a [u64], title: &'a str) -> Self {
Self {
data,
title,
color: Color::Cyan,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
}
impl Widget for BorderedSparkline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 6 || area.height < 3 {
return;
}
let block = Block::default()
.borders(Borders::ALL)
.title(self.title)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
block.render(area, buf);
let sparkline = RatatuiSparkline::default()
.block(Block::default())
.data(self.data)
.style(Style::default().fg(self.color));
sparkline.render(inner, buf);
}
}
pub struct AnimatedLatencySparkline<'a> {
data: &'a [u64],
frame: u8,
animation: SparklineAnimation,
warn_threshold: u64,
error_threshold: u64,
show_max: bool,
show_avg: bool,
}
impl<'a> AnimatedLatencySparkline<'a> {
pub fn new(data: &'a [u64]) -> Self {
Self {
data,
frame: 0,
animation: SparklineAnimation::None,
warn_threshold: 500,
error_threshold: 2000,
show_max: false,
show_avg: false,
}
}
pub fn frame(mut self, frame: u8) -> Self {
self.frame = frame;
self
}
pub fn animation(mut self, animation: SparklineAnimation) -> Self {
self.animation = animation;
self
}
pub fn warn_threshold(mut self, ms: u64) -> Self {
self.warn_threshold = ms;
self
}
pub fn error_threshold(mut self, ms: u64) -> Self {
self.error_threshold = ms;
self
}
pub fn show_max(mut self) -> Self {
self.show_max = true;
self
}
pub fn show_avg(mut self) -> Self {
self.show_avg = true;
self
}
fn get_base_color(&self) -> Color {
let max = self.data.iter().max().copied().unwrap_or(0);
if max >= self.error_threshold {
DEFAULT_ERROR_COLOR
} else if max >= self.warn_threshold {
DEFAULT_WARN_COLOR
} else {
DEFAULT_SUCCESS_COLOR
}
}
fn is_pulse_active(&self) -> bool {
matches!(self.animation, SparklineAnimation::Pulse) && (self.frame % 15) < 5
}
fn average(&self) -> u64 {
if self.data.is_empty() {
return 0;
}
self.data.iter().sum::<u64>() / self.data.len() as u64
}
fn max(&self) -> u64 {
self.data.iter().max().copied().unwrap_or(0)
}
fn apply_wave_animation(&self, data: &[u64]) -> Vec<u64> {
if !matches!(self.animation, SparklineAnimation::Wave) {
return data.to_vec();
}
let phase = (self.frame as f64 / 60.0) * std::f64::consts::PI * 2.0;
data.iter()
.enumerate()
.map(|(i, &v)| {
let wave = ((phase + i as f64 * 0.5).sin() * 0.1 + 1.0).max(0.9);
((v as f64 * wave) as u64).max(1)
})
.collect()
}
fn apply_flow_animation(&self, data: &[u64]) -> Vec<u64> {
if !matches!(self.animation, SparklineAnimation::Flow) || data.is_empty() {
return data.to_vec();
}
let shift = (self.frame / 10) as usize % data.len().max(1);
let mut result = data.to_vec();
result.rotate_left(shift);
result
}
}
impl Widget for AnimatedLatencySparkline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 5 || area.height < 1 || self.data.is_empty() {
return;
}
let base_color = self.get_base_color();
let pulse_active = self.is_pulse_active();
let animated_data = match self.animation {
SparklineAnimation::Wave => self.apply_wave_animation(self.data),
SparklineAnimation::Flow => self.apply_flow_animation(self.data),
_ => self.data.to_vec(),
};
let label_width = if self.show_max || self.show_avg {
10u16
} else {
0
};
let sparkline_width = area.width.saturating_sub(label_width);
let sparkline_area = Rect {
x: area.x,
y: area.y,
width: sparkline_width,
height: area.height,
};
let style = if pulse_active {
Style::default()
.fg(DEFAULT_HIGHLIGHT_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(base_color)
};
let sparkline = RatatuiSparkline::default()
.block(Block::default())
.data(&animated_data)
.style(style);
sparkline.render(sparkline_area, buf);
if pulse_active && sparkline_width > 0 {
let marker_x = area.x + sparkline_width.saturating_sub(1);
if marker_x < area.x + area.width {
buf.set_string(
marker_x,
area.y,
"█",
Style::default()
.fg(DEFAULT_HIGHLIGHT_COLOR)
.add_modifier(Modifier::BOLD),
);
}
}
if self.show_max || self.show_avg {
let label_x = area.x + sparkline_width + 1;
let mut label_y = area.y;
if self.show_max && area.height >= 1 {
let max_str = format_ms(self.max());
let max_label = format!("⬆{}", max_str);
let label_style = if pulse_active {
Style::default()
.fg(DEFAULT_HIGHLIGHT_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(base_color)
};
buf.set_string(label_x, label_y, &max_label, label_style);
label_y += 1;
}
if self.show_avg && label_y < area.y + area.height {
let avg_str = format_ms(self.average());
let avg_label = format!("~{}", avg_str);
buf.set_string(
label_x,
label_y,
&avg_label,
Style::default().fg(Color::Gray),
);
}
}
}
}
fn format_ms(ms: u64) -> String {
if ms >= 1000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
format!("{}ms", ms)
}
}
#[derive(Debug, Clone, Default)]
pub struct LatencyHistory {
values: std::collections::VecDeque<u64>,
max_size: usize,
cache: Vec<u64>,
cache_dirty: bool,
}
impl LatencyHistory {
pub fn new(max_size: usize) -> Self {
Self {
values: std::collections::VecDeque::with_capacity(max_size),
max_size,
cache: Vec::with_capacity(max_size),
cache_dirty: true,
}
}
pub fn push(&mut self, latency_ms: u64) {
if self.values.len() >= self.max_size {
self.values.pop_front(); }
self.values.push_back(latency_ms);
self.cache_dirty = true;
}
pub fn data(&mut self) -> &[u64] {
if self.cache_dirty {
self.cache.clear();
self.cache.extend(self.values.iter().copied());
self.cache_dirty = false;
}
&self.cache
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
pub fn len(&self) -> usize {
self.values.len()
}
pub fn average(&self) -> u64 {
if self.values.is_empty() {
return 0;
}
self.values.iter().sum::<u64>() / self.values.len() as u64
}
pub fn max(&self) -> u64 {
self.values.iter().max().copied().unwrap_or(0)
}
pub fn min(&self) -> u64 {
self.values.iter().min().copied().unwrap_or(0)
}
pub fn clear(&mut self) {
self.values.clear();
self.cache_dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_latency_history_ring_buffer() {
let mut history = LatencyHistory::new(5);
for i in 1..=5 {
history.push(i * 10);
}
assert_eq!(history.len(), 5);
assert_eq!(history.data(), &[10, 20, 30, 40, 50]);
history.push(60);
assert_eq!(history.len(), 5);
assert_eq!(history.data(), &[20, 30, 40, 50, 60]);
}
#[test]
fn test_latency_history_stats() {
let mut history = LatencyHistory::new(10);
history.push(100);
history.push(200);
history.push(300);
assert_eq!(history.average(), 200);
assert_eq!(history.max(), 300);
assert_eq!(history.min(), 100);
}
#[test]
fn test_latency_history_empty() {
let history = LatencyHistory::new(10);
assert!(history.is_empty());
assert_eq!(history.average(), 0);
assert_eq!(history.max(), 0);
assert_eq!(history.min(), 0);
}
#[test]
fn test_format_ms() {
assert_eq!(format_ms(50), "50ms");
assert_eq!(format_ms(999), "999ms");
assert_eq!(format_ms(1000), "1.0s");
assert_eq!(format_ms(1500), "1.5s");
assert_eq!(format_ms(10000), "10.0s");
}
#[test]
fn test_latency_sparkline_color_thresholds() {
let fast_data: Vec<u64> = vec![10, 20, 30, 40, 50];
let spark = LatencySparkline::new(&fast_data);
assert_eq!(spark.get_color(), DEFAULT_SUCCESS_COLOR);
let medium_data: Vec<u64> = vec![100, 200, 600, 400, 300];
let spark = LatencySparkline::new(&medium_data);
assert_eq!(spark.get_color(), DEFAULT_WARN_COLOR);
let slow_data: Vec<u64> = vec![100, 200, 3000, 400, 300];
let spark = LatencySparkline::new(&slow_data);
assert_eq!(spark.get_color(), DEFAULT_ERROR_COLOR);
}
#[test]
fn test_sparkline_animation_default_is_none() {
assert_eq!(SparklineAnimation::default(), SparklineAnimation::None);
}
#[test]
fn test_animated_sparkline_defaults() {
let data: Vec<u64> = vec![10, 20, 30];
let spark = AnimatedLatencySparkline::new(&data);
assert!(!spark.is_pulse_active()); }
#[test]
fn test_animated_sparkline_pulse_timing() {
let data: Vec<u64> = vec![10, 20, 30, 40, 50];
let spark = AnimatedLatencySparkline::new(&data)
.frame(0)
.animation(SparklineAnimation::Pulse);
assert!(spark.is_pulse_active());
let spark = AnimatedLatencySparkline::new(&data)
.frame(4)
.animation(SparklineAnimation::Pulse);
assert!(spark.is_pulse_active());
let spark = AnimatedLatencySparkline::new(&data)
.frame(5)
.animation(SparklineAnimation::Pulse);
assert!(!spark.is_pulse_active());
let spark = AnimatedLatencySparkline::new(&data)
.frame(14)
.animation(SparklineAnimation::Pulse);
assert!(!spark.is_pulse_active());
let spark = AnimatedLatencySparkline::new(&data)
.frame(15)
.animation(SparklineAnimation::Pulse);
assert!(spark.is_pulse_active());
}
#[test]
fn test_animated_sparkline_pulse_not_active_with_none_animation() {
let data: Vec<u64> = vec![10, 20, 30, 40, 50];
for frame in 0..60 {
let spark = AnimatedLatencySparkline::new(&data)
.frame(frame)
.animation(SparklineAnimation::None);
assert!(!spark.is_pulse_active(), "frame {} should not pulse", frame);
}
}
#[test]
fn test_animated_sparkline_wave_transforms_data() {
let data: Vec<u64> = vec![100, 100, 100, 100, 100];
let spark = AnimatedLatencySparkline::new(&data)
.frame(30)
.animation(SparklineAnimation::Wave);
let transformed = spark.apply_wave_animation(&data);
assert_eq!(transformed.len(), data.len());
}
#[test]
fn test_animated_sparkline_wave_returns_unchanged_for_non_wave() {
let data: Vec<u64> = vec![10, 20, 30, 40, 50];
let spark = AnimatedLatencySparkline::new(&data)
.frame(30)
.animation(SparklineAnimation::None);
let result = spark.apply_wave_animation(&data);
assert_eq!(result, data);
let spark = AnimatedLatencySparkline::new(&data)
.frame(30)
.animation(SparklineAnimation::Pulse);
let result = spark.apply_wave_animation(&data);
assert_eq!(result, data);
}
#[test]
fn test_animated_sparkline_flow_rotates_data() {
let data: Vec<u64> = vec![10, 20, 30, 40, 50];
let spark = AnimatedLatencySparkline::new(&data)
.frame(0)
.animation(SparklineAnimation::Flow);
let result = spark.apply_flow_animation(&data);
assert_eq!(result, vec![10, 20, 30, 40, 50]);
let spark = AnimatedLatencySparkline::new(&data)
.frame(10)
.animation(SparklineAnimation::Flow);
let result = spark.apply_flow_animation(&data);
assert_eq!(result, vec![20, 30, 40, 50, 10]);
let spark = AnimatedLatencySparkline::new(&data)
.frame(20)
.animation(SparklineAnimation::Flow);
let result = spark.apply_flow_animation(&data);
assert_eq!(result, vec![30, 40, 50, 10, 20]);
}
#[test]
fn test_animated_sparkline_flow_empty_data() {
let data: Vec<u64> = vec![];
let spark = AnimatedLatencySparkline::new(&data)
.frame(10)
.animation(SparklineAnimation::Flow);
let result = spark.apply_flow_animation(&data);
assert!(result.is_empty());
}
#[test]
fn test_animated_sparkline_color_thresholds() {
let fast_data: Vec<u64> = vec![10, 20, 30, 40, 50];
let spark = AnimatedLatencySparkline::new(&fast_data);
assert_eq!(spark.get_base_color(), DEFAULT_SUCCESS_COLOR);
let medium_data: Vec<u64> = vec![100, 200, 600, 400, 300];
let spark = AnimatedLatencySparkline::new(&medium_data);
assert_eq!(spark.get_base_color(), DEFAULT_WARN_COLOR);
let slow_data: Vec<u64> = vec![100, 200, 3000, 400, 300];
let spark = AnimatedLatencySparkline::new(&slow_data);
assert_eq!(spark.get_base_color(), DEFAULT_ERROR_COLOR);
}
#[test]
fn test_animated_sparkline_custom_thresholds() {
let data: Vec<u64> = vec![100, 200, 300];
let spark = AnimatedLatencySparkline::new(&data);
assert_eq!(spark.get_base_color(), DEFAULT_SUCCESS_COLOR);
let spark = AnimatedLatencySparkline::new(&data)
.warn_threshold(250)
.error_threshold(400);
assert_eq!(spark.get_base_color(), DEFAULT_WARN_COLOR);
let spark = AnimatedLatencySparkline::new(&data)
.warn_threshold(100)
.error_threshold(200);
assert_eq!(spark.get_base_color(), DEFAULT_ERROR_COLOR); }
#[test]
fn test_animated_sparkline_builder_chain() {
let data: Vec<u64> = vec![10, 20, 30];
let spark = AnimatedLatencySparkline::new(&data)
.frame(30)
.animation(SparklineAnimation::Pulse)
.warn_threshold(100)
.error_threshold(500)
.show_max()
.show_avg();
assert!(spark.is_pulse_active() || !spark.is_pulse_active()); }
}