use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, RenderDirection, Sparkline as RatatuiSparkline};
use super::{Component, RenderContext};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum SparklineDirection {
#[default]
LeftToRight,
RightToLeft,
}
impl From<SparklineDirection> for RenderDirection {
fn from(dir: SparklineDirection) -> Self {
match dir {
SparklineDirection::LeftToRight => RenderDirection::LeftToRight,
SparklineDirection::RightToLeft => RenderDirection::RightToLeft,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum SparklineMessage {
Push(f64),
PushBounded(f64, usize),
SetData(Vec<f64>),
Clear,
SetMaxDisplayPoints(Option<usize>),
}
pub type SparklineOutput = ();
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SparklineState {
data: Vec<f64>,
max_display_points: Option<usize>,
title: Option<String>,
direction: SparklineDirection,
color: Option<Color>,
}
impl SparklineState {
pub fn new() -> Self {
Self::default()
}
pub fn with_data(data: Vec<f64>) -> Self {
Self {
data,
..Self::default()
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_direction(mut self, direction: SparklineDirection) -> Self {
self.direction = direction;
self
}
pub fn with_max_display_points(mut self, max: usize) -> Self {
self.max_display_points = Some(max);
self
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn data(&self) -> &[f64] {
&self.data
}
pub fn push(&mut self, value: f64) {
self.data.push(value);
}
pub fn push_bounded(&mut self, value: f64, max_len: usize) {
self.data.push(value);
if self.data.len() > max_len {
let excess = self.data.len() - max_len;
self.data.drain(..excess);
}
}
pub fn clear(&mut self) {
self.data.clear();
}
pub fn len(&self) -> usize {
self.data.len()
}
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub fn last(&self) -> Option<f64> {
self.data.last().copied()
}
pub fn min(&self) -> Option<f64> {
self.data.iter().copied().reduce(f64::min)
}
pub fn max(&self) -> Option<f64> {
self.data.iter().copied().reduce(f64::max)
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn direction(&self) -> &SparklineDirection {
&self.direction
}
pub fn max_display_points(&self) -> Option<usize> {
self.max_display_points
}
pub fn set_max_display_points(&mut self, max: Option<usize>) {
self.max_display_points = max;
}
pub fn color(&self) -> Option<Color> {
self.color
}
pub fn set_color(&mut self, color: Option<Color>) {
self.color = color;
}
pub fn update(&mut self, msg: SparklineMessage) -> Option<SparklineOutput> {
Sparkline::update(self, msg)
}
}
pub struct Sparkline;
impl Component for Sparkline {
type State = SparklineState;
type Message = SparklineMessage;
type Output = SparklineOutput;
fn init() -> Self::State {
SparklineState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SparklineMessage::Push(value) => {
state.push(value);
}
SparklineMessage::PushBounded(value, max_len) => {
state.push_bounded(value, max_len);
}
SparklineMessage::SetData(data) => {
state.data = data;
}
SparklineMessage::Clear => {
state.clear();
}
SparklineMessage::SetMaxDisplayPoints(max) => {
state.max_display_points = max;
}
}
None
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
let display_data = match state.max_display_points {
Some(n) if state.data.len() > n => &state.data[state.data.len() - n..],
_ => &state.data,
};
let normalized: Vec<u64> = if display_data.is_empty() {
Vec::new()
} else {
let min_val = display_data.iter().copied().reduce(f64::min).unwrap_or(0.0);
let max_val = display_data.iter().copied().reduce(f64::max).unwrap_or(1.0);
let range = (max_val - min_val).max(f64::EPSILON);
display_data
.iter()
.map(|&v| ((v - min_val) / range * 100.0) as u64)
.collect()
};
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if let Some(color) = state.color {
Style::default().fg(color)
} else {
ctx.theme.normal_style()
};
let direction: RenderDirection = state.direction.clone().into();
let mut sparkline = RatatuiSparkline::default()
.data(&normalized)
.direction(direction)
.style(style);
if let Some(title) = &state.title {
sparkline =
sparkline.block(Block::default().title(title.as_str()).borders(Borders::ALL));
}
let annotation =
crate::annotation::Annotation::new(crate::annotation::WidgetType::Sparkline)
.with_id("sparkline")
.with_label(state.title.as_deref().unwrap_or(""));
let annotated = crate::annotation::Annotate::new(sparkline, annotation);
ctx.frame.render_widget(annotated, ctx.area);
}
}
#[cfg(test)]
mod tests;