1#![forbid(unsafe_code)]
2
3use crate::{MeasurableWidget, SizeConstraints, Widget};
20use ftui_core::geometry::{Rect, Size};
21use ftui_render::cell::{Cell, PackedRgba};
22use ftui_render::frame::Frame;
23use ftui_style::Style;
24
25const SPARK_CHARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
27
28#[derive(Debug, Clone)]
45pub struct Sparkline<'a> {
46 data: &'a [f64],
48 min: Option<f64>,
50 max: Option<f64>,
52 style: Style,
54 gradient: Option<(PackedRgba, PackedRgba)>,
56 baseline: f64,
58}
59
60impl<'a> Sparkline<'a> {
61 #[must_use]
63 pub fn new(data: &'a [f64]) -> Self {
64 Self {
65 data,
66 min: None,
67 max: None,
68 style: Style::default(),
69 gradient: None,
70 baseline: 0.0,
71 }
72 }
73
74 #[must_use]
78 pub fn min(mut self, min: f64) -> Self {
79 self.min = Some(min);
80 self
81 }
82
83 #[must_use]
87 pub fn max(mut self, max: f64) -> Self {
88 self.max = Some(max);
89 self
90 }
91
92 #[must_use]
94 pub fn bounds(mut self, min: f64, max: f64) -> Self {
95 self.min = Some(min);
96 self.max = Some(max);
97 self
98 }
99
100 #[must_use]
102 pub fn style(mut self, style: Style) -> Self {
103 self.style = style;
104 self
105 }
106
107 #[must_use]
112 pub fn gradient(mut self, low_color: PackedRgba, high_color: PackedRgba) -> Self {
113 self.gradient = Some((low_color, high_color));
114 self
115 }
116
117 #[must_use]
122 pub fn baseline(mut self, baseline: f64) -> Self {
123 self.baseline = baseline;
124 self
125 }
126
127 fn compute_bounds(&self) -> (f64, f64) {
129 let data_min = self
130 .min
131 .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
132 let data_max = self
133 .max
134 .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
135
136 let min = if data_min.is_finite() { data_min } else { 0.0 };
138 let max = if data_max.is_finite() { data_max } else { 1.0 };
139
140 if min >= max {
141 (min - 0.5, max + 0.5)
143 } else {
144 (min, max)
145 }
146 }
147
148 fn value_to_bar_index(&self, value: f64, min: f64, max: f64) -> usize {
150 if !value.is_finite() {
151 return 0;
152 }
153
154 if value <= self.baseline {
155 return 0;
156 }
157
158 let range = max - min;
159 if range <= 0.0 {
160 return 4; }
162
163 let normalized = (value - min) / range;
164 let clamped = normalized.clamp(0.0, 1.0);
165 (clamped * 8.0).round() as usize
167 }
168
169 fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
171 let t = t.clamp(0.0, 1.0) as f32;
172 let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
173 let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
174 let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
175 let a = (low.a() as f32 * (1.0 - t) + high.a() as f32 * t).round() as u8;
176 PackedRgba::rgba(r, g, b, a)
177 }
178
179 pub fn render_to_string(&self) -> String {
181 if self.data.is_empty() {
182 return String::new();
183 }
184
185 let (min, max) = self.compute_bounds();
186 self.data
187 .iter()
188 .map(|&v| {
189 let idx = self.value_to_bar_index(v, min, max);
190 SPARK_CHARS[idx]
191 })
192 .collect()
193 }
194}
195
196impl Default for Sparkline<'_> {
197 fn default() -> Self {
198 Self::new(&[])
199 }
200}
201
202impl Widget for Sparkline<'_> {
203 fn render(&self, area: Rect, frame: &mut Frame) {
204 #[cfg(feature = "tracing")]
205 let _span = tracing::debug_span!(
206 "widget_render",
207 widget = "Sparkline",
208 x = area.x,
209 y = area.y,
210 w = area.width,
211 h = area.height,
212 data_len = self.data.len()
213 )
214 .entered();
215
216 if area.is_empty() || self.data.is_empty() {
217 return;
218 }
219
220 let deg = frame.buffer.degradation;
221
222 if !deg.render_content() {
224 return;
225 }
226
227 let (min, max) = self.compute_bounds();
228 let range = max - min;
229
230 let display_count = (area.width as usize).min(self.data.len());
232
233 for (i, &value) in self.data.iter().take(display_count).enumerate() {
234 let x = area.x + i as u16;
235 let y = area.y;
236
237 if x >= area.right() {
238 break;
239 }
240
241 let bar_idx = self.value_to_bar_index(value, min, max);
242 let ch = SPARK_CHARS[bar_idx];
243
244 let mut cell = Cell::from_char(ch);
245
246 if deg.apply_styling() {
248 crate::apply_style(&mut cell, self.style);
250
251 if let Some((low_color, high_color)) = self.gradient {
253 let t = if range > 0.0 {
254 (value - min) / range
255 } else {
256 0.5
257 };
258 cell.fg = Self::lerp_color(low_color, high_color, t);
259 } else if self.style.fg.is_none() {
260 cell.fg = PackedRgba::WHITE;
262 }
263 }
264
265 frame.buffer.set_fast(x, y, cell);
266 }
267 }
268}
269
270impl MeasurableWidget for Sparkline<'_> {
271 fn measure(&self, _available: Size) -> SizeConstraints {
272 if self.data.is_empty() {
273 return SizeConstraints::ZERO;
274 }
275
276 let width = self.data.len() as u16;
279
280 SizeConstraints {
281 min: Size::new(1, 1), preferred: Size::new(width, 1),
283 max: Some(Size::new(width, 1)), }
285 }
286
287 fn has_intrinsic_size(&self) -> bool {
288 !self.data.is_empty()
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use ftui_render::grapheme_pool::GraphemePool;
296
297 #[test]
300 fn empty_data() {
301 let sparkline = Sparkline::new(&[]);
302 assert_eq!(sparkline.render_to_string(), "");
303 }
304
305 #[test]
306 fn single_value() {
307 let sparkline = Sparkline::new(&[5.0]);
308 let s = sparkline.render_to_string();
310 assert_eq!(s.chars().count(), 1);
311 }
312
313 #[test]
314 fn constant_values() {
315 let data = vec![5.0, 5.0, 5.0, 5.0];
316 let sparkline = Sparkline::new(&data);
317 let s = sparkline.render_to_string();
318 assert_eq!(s.chars().count(), 4);
320 assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
321 }
322
323 #[test]
324 fn ascending_values() {
325 let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
326 let sparkline = Sparkline::new(&data);
327 let s = sparkline.render_to_string();
328 let chars: Vec<char> = s.chars().collect();
329 assert_eq!(chars[0], ' ');
331 assert_eq!(chars[8], '█');
332 }
333
334 #[test]
335 fn descending_values() {
336 let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
337 let sparkline = Sparkline::new(&data);
338 let s = sparkline.render_to_string();
339 let chars: Vec<char> = s.chars().collect();
340 assert_eq!(chars[0], '█');
342 assert_eq!(chars[8], ' ');
343 }
344
345 #[test]
346 fn explicit_bounds() {
347 let data = vec![5.0, 5.0, 5.0];
348 let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
349 let s = sparkline.render_to_string();
350 let chars: Vec<char> = s.chars().collect();
352 assert_eq!(chars[0], '▄');
353 }
354
355 #[test]
356 fn min_max_explicit() {
357 let data = vec![0.0, 50.0, 100.0];
358 let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
359 let s = sparkline.render_to_string();
360 let chars: Vec<char> = s.chars().collect();
361 assert_eq!(chars[0], ' '); assert_eq!(chars[1], '▄'); assert_eq!(chars[2], '█'); }
365
366 #[test]
367 fn negative_values() {
368 let data = vec![-10.0, 0.0, 10.0];
369 let sparkline = Sparkline::new(&data);
370 let s = sparkline.render_to_string();
371 let chars: Vec<char> = s.chars().collect();
372 assert_eq!(chars[0], ' '); assert_eq!(chars[2], '█'); }
375
376 #[test]
377 fn nan_values_handled() {
378 let data = vec![1.0, f64::NAN, 3.0];
379 let sparkline = Sparkline::new(&data);
380 let s = sparkline.render_to_string();
381 let chars: Vec<char> = s.chars().collect();
383 assert_eq!(chars[1], ' ');
384 }
385
386 #[test]
387 fn infinity_values_handled() {
388 let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
389 let sparkline = Sparkline::new(&data);
390 let s = sparkline.render_to_string();
391 assert_eq!(s.chars().count(), 3);
393 }
394
395 #[test]
398 fn render_empty_area() {
399 let data = vec![1.0, 2.0, 3.0];
400 let sparkline = Sparkline::new(&data);
401 let area = Rect::new(0, 0, 0, 0);
402 let mut pool = GraphemePool::new();
403 let mut frame = Frame::new(1, 1, &mut pool);
404 Widget::render(&sparkline, area, &mut frame);
405 }
407
408 #[test]
409 fn render_basic() {
410 let data = vec![0.0, 0.5, 1.0];
411 let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
412 let area = Rect::new(0, 0, 3, 1);
413 let mut pool = GraphemePool::new();
414 let mut frame = Frame::new(3, 1, &mut pool);
415 Widget::render(&sparkline, area, &mut frame);
416
417 let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
418 let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
419 let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
420
421 assert_eq!(c0, Some(' ')); assert_eq!(c1, Some('▄')); assert_eq!(c2, Some('█')); }
425
426 #[test]
427 fn render_truncates_to_width() {
428 let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
429 let sparkline = Sparkline::new(&data);
430 let area = Rect::new(0, 0, 10, 1);
431 let mut pool = GraphemePool::new();
432 let mut frame = Frame::new(10, 1, &mut pool);
433 Widget::render(&sparkline, area, &mut frame);
434
435 for x in 0..10 {
437 let cell = frame.buffer.get(x, 0).unwrap();
438 assert!(cell.content.as_char().is_some());
439 }
440 }
441
442 #[test]
443 fn render_with_style() {
444 let data = vec![1.0];
445 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
446 let area = Rect::new(0, 0, 1, 1);
447 let mut pool = GraphemePool::new();
448 let mut frame = Frame::new(1, 1, &mut pool);
449 Widget::render(&sparkline, area, &mut frame);
450
451 let cell = frame.buffer.get(0, 0).unwrap();
452 assert_eq!(cell.fg, PackedRgba::GREEN);
453 }
454
455 #[test]
456 fn render_with_gradient() {
457 let data = vec![0.0, 0.5, 1.0];
458 let sparkline = Sparkline::new(&data)
459 .bounds(0.0, 1.0)
460 .gradient(PackedRgba::BLUE, PackedRgba::RED);
461 let area = Rect::new(0, 0, 3, 1);
462 let mut pool = GraphemePool::new();
463 let mut frame = Frame::new(3, 1, &mut pool);
464 Widget::render(&sparkline, area, &mut frame);
465
466 let c0 = frame.buffer.get(0, 0).unwrap();
467 let c2 = frame.buffer.get(2, 0).unwrap();
468
469 assert_eq!(c0.fg, PackedRgba::BLUE);
471 assert_eq!(c2.fg, PackedRgba::RED);
473 }
474
475 #[test]
478 fn degradation_skeleton_skips() {
479 use ftui_render::budget::DegradationLevel;
480
481 let data = vec![1.0, 2.0, 3.0];
482 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
483 let area = Rect::new(0, 0, 3, 1);
484 let mut pool = GraphemePool::new();
485 let mut frame = Frame::new(3, 1, &mut pool);
486 frame.buffer.degradation = DegradationLevel::Skeleton;
487 Widget::render(&sparkline, area, &mut frame);
488
489 for x in 0..3 {
491 assert!(
492 frame.buffer.get(x, 0).unwrap().is_empty(),
493 "cell at x={x} should be empty at Skeleton"
494 );
495 }
496 }
497
498 #[test]
499 fn degradation_no_styling_renders_without_color() {
500 use ftui_render::budget::DegradationLevel;
501
502 let data = vec![0.5];
503 let sparkline = Sparkline::new(&data)
504 .bounds(0.0, 1.0)
505 .style(Style::new().fg(PackedRgba::GREEN));
506 let area = Rect::new(0, 0, 1, 1);
507 let mut pool = GraphemePool::new();
508 let mut frame = Frame::new(1, 1, &mut pool);
509 frame.buffer.degradation = DegradationLevel::NoStyling;
510 Widget::render(&sparkline, area, &mut frame);
511
512 let cell = frame.buffer.get(0, 0).unwrap();
514 assert!(cell.content.as_char().is_some());
515 assert_ne!(cell.fg, PackedRgba::GREEN);
517 }
518
519 #[test]
522 fn lerp_color_endpoints() {
523 let low = PackedRgba::rgb(0, 0, 0);
524 let high = PackedRgba::rgb(255, 255, 255);
525
526 assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
527 assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
528 }
529
530 #[test]
531 fn lerp_color_midpoint() {
532 let low = PackedRgba::rgb(0, 0, 0);
533 let high = PackedRgba::rgb(255, 255, 255);
534 let mid = Sparkline::lerp_color(low, high, 0.5);
535
536 assert_eq!(mid.r(), 128);
537 assert_eq!(mid.g(), 128);
538 assert_eq!(mid.b(), 128);
539 }
540
541 #[test]
542 fn lerp_color_interpolates_alpha() {
543 let low = PackedRgba::rgba(0, 0, 0, 0);
544 let high = PackedRgba::rgba(255, 255, 255, 255);
545 let mid = Sparkline::lerp_color(low, high, 0.5);
546
547 assert_eq!(mid.r(), 128);
548 assert_eq!(mid.g(), 128);
549 assert_eq!(mid.b(), 128);
550 assert_eq!(mid.a(), 128);
551 }
552
553 #[test]
556 fn measure_empty_sparkline() {
557 let sparkline = Sparkline::new(&[]);
558 let c = sparkline.measure(Size::MAX);
559 assert_eq!(c, SizeConstraints::ZERO);
560 assert!(!sparkline.has_intrinsic_size());
561 }
562
563 #[test]
564 fn measure_single_value() {
565 let data = [5.0];
566 let sparkline = Sparkline::new(&data);
567 let c = sparkline.measure(Size::MAX);
568
569 assert_eq!(c.preferred.width, 1);
570 assert_eq!(c.preferred.height, 1);
571 assert!(sparkline.has_intrinsic_size());
572 }
573
574 #[test]
575 fn measure_multiple_values() {
576 let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
577 let sparkline = Sparkline::new(&data);
578 let c = sparkline.measure(Size::MAX);
579
580 assert_eq!(c.preferred.width, 50);
581 assert_eq!(c.preferred.height, 1);
582 assert_eq!(c.min.width, 1);
583 assert_eq!(c.min.height, 1);
584 }
585
586 #[test]
587 fn measure_max_equals_preferred() {
588 let data = [1.0, 2.0, 3.0];
589 let sparkline = Sparkline::new(&data);
590 let c = sparkline.measure(Size::MAX);
591
592 assert_eq!(c.max, Some(Size::new(3, 1)));
593 }
594}