#![forbid(unsafe_code)]
use crate::{MeasurableWidget, SizeConstraints, Widget, clear_text_row};
use ftui_core::geometry::{Rect, Size};
use ftui_render::cell::{Cell, PackedRgba};
use ftui_render::frame::Frame;
use ftui_style::Style;
const SPARK_CHARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
#[derive(Debug, Clone)]
pub struct Sparkline<'a> {
data: &'a [f64],
min: Option<f64>,
max: Option<f64>,
style: Style,
gradient: Option<(PackedRgba, PackedRgba)>,
baseline: f64,
}
impl<'a> Sparkline<'a> {
#[must_use]
pub fn new(data: &'a [f64]) -> Self {
Self {
data,
min: None,
max: None,
style: Style::default(),
gradient: None,
baseline: 0.0,
}
}
#[must_use]
pub fn min(mut self, min: f64) -> Self {
self.min = Some(min);
self
}
#[must_use]
pub fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
#[must_use]
pub fn bounds(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn gradient(mut self, low_color: PackedRgba, high_color: PackedRgba) -> Self {
self.gradient = Some((low_color, high_color));
self
}
#[must_use]
pub fn baseline(mut self, baseline: f64) -> Self {
self.baseline = baseline;
self
}
fn compute_bounds(&self) -> (f64, f64) {
let data_min = self
.min
.unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
let data_max = self
.max
.unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
let min = if data_min.is_finite() { data_min } else { 0.0 };
let max = if data_max.is_finite() { data_max } else { 1.0 };
if min >= max {
(min - 0.5, max + 0.5)
} else {
(min, max)
}
}
fn value_to_bar_index(&self, value: f64, min: f64, max: f64) -> usize {
if !value.is_finite() {
return 0;
}
if value <= self.baseline {
return 0;
}
let range = max - min;
if range <= 0.0 {
return 4; }
let normalized = (value - min) / range;
let clamped = normalized.clamp(0.0, 1.0);
(clamped * 8.0).round() as usize
}
fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) } as f32;
let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
let a = (low.a() as f32 * (1.0 - t) + high.a() as f32 * t).round() as u8;
PackedRgba::rgba(r, g, b, a)
}
pub fn render_to_string(&self) -> String {
if self.data.is_empty() {
return String::new();
}
let (min, max) = self.compute_bounds();
self.data
.iter()
.map(|&v| {
let idx = self.value_to_bar_index(v, min, max);
SPARK_CHARS[idx]
})
.collect()
}
}
impl Default for Sparkline<'_> {
fn default() -> Self {
Self::new(&[])
}
}
impl Widget for Sparkline<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Sparkline",
x = area.x,
y = area.y,
w = area.width,
h = area.height,
data_len = self.data.len()
)
.entered();
if area.is_empty() {
return;
}
let deg = frame.buffer.degradation;
if !deg.render_content() {
return;
}
let base_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
clear_text_row(frame, area, base_style);
if self.data.is_empty() {
return;
}
let (min, max) = self.compute_bounds();
let range = max - min;
let display_count = (area.width as usize).min(self.data.len());
for (i, &value) in self.data.iter().take(display_count).enumerate() {
let x = area.x + i as u16;
let y = area.y;
if x >= area.right() {
break;
}
let bar_idx = self.value_to_bar_index(value, min, max);
let ch = SPARK_CHARS[bar_idx];
let mut cell = Cell::from_char(ch);
if deg.apply_styling() {
crate::apply_style(&mut cell, self.style);
if let Some((low_color, high_color)) = self.gradient {
let t = if range > 0.0 {
(value - min) / range
} else {
0.5
};
cell.fg = Self::lerp_color(low_color, high_color, t);
} else if self.style.fg.is_none() {
cell.fg = PackedRgba::WHITE;
}
}
frame.buffer.set_fast(x, y, cell);
}
}
}
impl MeasurableWidget for Sparkline<'_> {
fn measure(&self, _available: Size) -> SizeConstraints {
if self.data.is_empty() {
return SizeConstraints::ZERO;
}
let width = self.data.len() as u16;
SizeConstraints {
min: Size::new(1, 1), preferred: Size::new(width, 1),
max: Some(Size::new(width, 1)), }
}
fn has_intrinsic_size(&self) -> bool {
!self.data.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::grapheme_pool::GraphemePool;
#[test]
fn empty_data() {
let sparkline = Sparkline::new(&[]);
assert_eq!(sparkline.render_to_string(), "");
}
#[test]
fn single_value() {
let sparkline = Sparkline::new(&[5.0]);
let s = sparkline.render_to_string();
assert_eq!(s.chars().count(), 1);
}
#[test]
fn constant_values() {
let data = vec![5.0, 5.0, 5.0, 5.0];
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
assert_eq!(s.chars().count(), 4);
assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
}
#[test]
fn ascending_values() {
let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[0], ' ');
assert_eq!(chars[8], '█');
}
#[test]
fn descending_values() {
let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[0], '█');
assert_eq!(chars[8], ' ');
}
#[test]
fn explicit_bounds() {
let data = vec![5.0, 5.0, 5.0];
let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[0], '▄');
}
#[test]
fn min_max_explicit() {
let data = vec![0.0, 50.0, 100.0];
let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[0], ' '); assert_eq!(chars[1], '▄'); assert_eq!(chars[2], '█'); }
#[test]
fn negative_values() {
let data = vec![-10.0, 0.0, 10.0];
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[0], ' '); assert_eq!(chars[2], '█'); }
#[test]
fn nan_values_handled() {
let data = vec![1.0, f64::NAN, 3.0];
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
let chars: Vec<char> = s.chars().collect();
assert_eq!(chars[1], ' ');
}
#[test]
fn infinity_values_handled() {
let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
let sparkline = Sparkline::new(&data);
let s = sparkline.render_to_string();
assert_eq!(s.chars().count(), 3);
}
#[test]
fn render_empty_area() {
let data = vec![1.0, 2.0, 3.0];
let sparkline = Sparkline::new(&data);
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
Widget::render(&sparkline, area, &mut frame);
}
#[test]
fn render_basic() {
let data = vec![0.0, 0.5, 1.0];
let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
let area = Rect::new(0, 0, 3, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(3, 1, &mut pool);
Widget::render(&sparkline, area, &mut frame);
let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
assert_eq!(c0, Some(' ')); assert_eq!(c1, Some('▄')); assert_eq!(c2, Some('█')); }
#[test]
fn render_truncates_to_width() {
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let sparkline = Sparkline::new(&data);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
Widget::render(&sparkline, area, &mut frame);
for x in 0..10 {
let cell = frame.buffer.get(x, 0).unwrap();
assert!(cell.content.as_char().is_some());
}
}
#[test]
fn render_with_style() {
let data = vec![1.0];
let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
let area = Rect::new(0, 0, 1, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
Widget::render(&sparkline, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.fg, PackedRgba::GREEN);
}
#[test]
fn render_with_gradient() {
let data = vec![0.0, 0.5, 1.0];
let sparkline = Sparkline::new(&data)
.bounds(0.0, 1.0)
.gradient(PackedRgba::BLUE, PackedRgba::RED);
let area = Rect::new(0, 0, 3, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(3, 1, &mut pool);
Widget::render(&sparkline, area, &mut frame);
let c0 = frame.buffer.get(0, 0).unwrap();
let c2 = frame.buffer.get(2, 0).unwrap();
assert_eq!(c0.fg, PackedRgba::BLUE);
assert_eq!(c2.fg, PackedRgba::RED);
}
#[test]
fn degradation_skeleton_skips() {
use ftui_render::budget::DegradationLevel;
let data = vec![1.0, 2.0, 3.0];
let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
let area = Rect::new(0, 0, 3, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(3, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
Widget::render(&sparkline, area, &mut frame);
for x in 0..3 {
assert!(
frame.buffer.get(x, 0).unwrap().is_empty(),
"cell at x={x} should be empty at Skeleton"
);
}
}
#[test]
fn degradation_no_styling_renders_without_color() {
use ftui_render::budget::DegradationLevel;
let data = vec![0.5];
let sparkline = Sparkline::new(&data)
.bounds(0.0, 1.0)
.style(Style::new().fg(PackedRgba::GREEN));
let area = Rect::new(0, 0, 1, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
Widget::render(&sparkline, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.content.as_char().is_some());
assert_ne!(cell.fg, PackedRgba::GREEN);
}
#[test]
fn render_shorter_data_clears_stale_suffix() {
let long = Sparkline::new(&[0.0, 0.5, 1.0, 0.75]).bounds(0.0, 1.0);
let short = Sparkline::new(&[1.0]);
let area = Rect::new(0, 0, 4, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(4, 1, &mut pool);
Widget::render(&long, area, &mut frame);
Widget::render(&short, area, &mut frame);
let row: String = (0..4)
.map(|x| {
frame
.buffer
.get(x, 0)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ')
})
.collect();
assert_eq!(row, "▄ ");
}
#[test]
fn render_empty_data_clears_stale_sparkline() {
let long = Sparkline::new(&[0.0, 0.5, 1.0]).bounds(0.0, 1.0);
let empty = Sparkline::new(&[]);
let area = Rect::new(0, 0, 3, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(3, 1, &mut pool);
Widget::render(&long, area, &mut frame);
Widget::render(&empty, area, &mut frame);
for x in 0..3 {
assert_eq!(
frame
.buffer
.get(x, 0)
.and_then(|cell| cell.content.as_char()),
Some(' ')
);
}
}
#[test]
fn lerp_color_endpoints() {
let low = PackedRgba::rgb(0, 0, 0);
let high = PackedRgba::rgb(255, 255, 255);
assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
}
#[test]
fn lerp_color_midpoint() {
let low = PackedRgba::rgb(0, 0, 0);
let high = PackedRgba::rgb(255, 255, 255);
let mid = Sparkline::lerp_color(low, high, 0.5);
assert_eq!(mid.r(), 128);
assert_eq!(mid.g(), 128);
assert_eq!(mid.b(), 128);
}
#[test]
fn lerp_color_interpolates_alpha() {
let low = PackedRgba::rgba(0, 0, 0, 0);
let high = PackedRgba::rgba(255, 255, 255, 255);
let mid = Sparkline::lerp_color(low, high, 0.5);
assert_eq!(mid.r(), 128);
assert_eq!(mid.g(), 128);
assert_eq!(mid.b(), 128);
assert_eq!(mid.a(), 128);
}
#[test]
fn measure_empty_sparkline() {
let sparkline = Sparkline::new(&[]);
let c = sparkline.measure(Size::MAX);
assert_eq!(c, SizeConstraints::ZERO);
assert!(!sparkline.has_intrinsic_size());
}
#[test]
fn measure_single_value() {
let data = [5.0];
let sparkline = Sparkline::new(&data);
let c = sparkline.measure(Size::MAX);
assert_eq!(c.preferred.width, 1);
assert_eq!(c.preferred.height, 1);
assert!(sparkline.has_intrinsic_size());
}
#[test]
fn measure_multiple_values() {
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let sparkline = Sparkline::new(&data);
let c = sparkline.measure(Size::MAX);
assert_eq!(c.preferred.width, 50);
assert_eq!(c.preferred.height, 1);
assert_eq!(c.min.width, 1);
assert_eq!(c.min.height, 1);
}
#[test]
fn measure_max_equals_preferred() {
let data = [1.0, 2.0, 3.0];
let sparkline = Sparkline::new(&data);
let c = sparkline.measure(Size::MAX);
assert_eq!(c.max, Some(Size::new(3, 1)));
}
}