1#![forbid(unsafe_code)]
2
3use crate::{MeasurableWidget, SizeConstraints, Widget, clear_text_row};
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 = if t.is_nan() { 0.0 } else { 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() {
217 return;
218 }
219
220 let deg = frame.buffer.degradation;
221
222 if !deg.render_content() {
224 return;
225 }
226
227 let base_style = if deg.apply_styling() {
228 self.style
229 } else {
230 Style::default()
231 };
232 clear_text_row(frame, area, base_style);
233
234 if self.data.is_empty() {
235 return;
236 }
237
238 let (min, max) = self.compute_bounds();
239 let range = max - min;
240
241 let display_count = (area.width as usize).min(self.data.len());
243
244 for (i, &value) in self.data.iter().take(display_count).enumerate() {
245 let x = area.x + i as u16;
246 let y = area.y;
247
248 if x >= area.right() {
249 break;
250 }
251
252 let bar_idx = self.value_to_bar_index(value, min, max);
253 let ch = SPARK_CHARS[bar_idx];
254
255 let mut cell = Cell::from_char(ch);
256
257 if deg.apply_styling() {
259 crate::apply_style(&mut cell, self.style);
261
262 if let Some((low_color, high_color)) = self.gradient {
264 let t = if range > 0.0 {
265 (value - min) / range
266 } else {
267 0.5
268 };
269 cell.fg = Self::lerp_color(low_color, high_color, t);
270 } else if self.style.fg.is_none() {
271 cell.fg = PackedRgba::WHITE;
273 }
274 }
275
276 frame.buffer.set_fast(x, y, cell);
277 }
278 }
279}
280
281impl MeasurableWidget for Sparkline<'_> {
282 fn measure(&self, _available: Size) -> SizeConstraints {
283 if self.data.is_empty() {
284 return SizeConstraints::ZERO;
285 }
286
287 let width = self.data.len() as u16;
290
291 SizeConstraints {
292 min: Size::new(1, 1), preferred: Size::new(width, 1),
294 max: Some(Size::new(width, 1)), }
296 }
297
298 fn has_intrinsic_size(&self) -> bool {
299 !self.data.is_empty()
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use ftui_render::grapheme_pool::GraphemePool;
307
308 #[test]
311 fn empty_data() {
312 let sparkline = Sparkline::new(&[]);
313 assert_eq!(sparkline.render_to_string(), "");
314 }
315
316 #[test]
317 fn single_value() {
318 let sparkline = Sparkline::new(&[5.0]);
319 let s = sparkline.render_to_string();
321 assert_eq!(s.chars().count(), 1);
322 }
323
324 #[test]
325 fn constant_values() {
326 let data = vec![5.0, 5.0, 5.0, 5.0];
327 let sparkline = Sparkline::new(&data);
328 let s = sparkline.render_to_string();
329 assert_eq!(s.chars().count(), 4);
331 assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
332 }
333
334 #[test]
335 fn ascending_values() {
336 let data: Vec<f64> = (0..9).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 descending_values() {
347 let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
348 let sparkline = Sparkline::new(&data);
349 let s = sparkline.render_to_string();
350 let chars: Vec<char> = s.chars().collect();
351 assert_eq!(chars[0], '█');
353 assert_eq!(chars[8], ' ');
354 }
355
356 #[test]
357 fn explicit_bounds() {
358 let data = vec![5.0, 5.0, 5.0];
359 let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
360 let s = sparkline.render_to_string();
361 let chars: Vec<char> = s.chars().collect();
363 assert_eq!(chars[0], '▄');
364 }
365
366 #[test]
367 fn min_max_explicit() {
368 let data = vec![0.0, 50.0, 100.0];
369 let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
370 let s = sparkline.render_to_string();
371 let chars: Vec<char> = s.chars().collect();
372 assert_eq!(chars[0], ' '); assert_eq!(chars[1], '▄'); assert_eq!(chars[2], '█'); }
376
377 #[test]
378 fn negative_values() {
379 let data = vec![-10.0, 0.0, 10.0];
380 let sparkline = Sparkline::new(&data);
381 let s = sparkline.render_to_string();
382 let chars: Vec<char> = s.chars().collect();
383 assert_eq!(chars[0], ' '); assert_eq!(chars[2], '█'); }
386
387 #[test]
388 fn nan_values_handled() {
389 let data = vec![1.0, f64::NAN, 3.0];
390 let sparkline = Sparkline::new(&data);
391 let s = sparkline.render_to_string();
392 let chars: Vec<char> = s.chars().collect();
394 assert_eq!(chars[1], ' ');
395 }
396
397 #[test]
398 fn infinity_values_handled() {
399 let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
400 let sparkline = Sparkline::new(&data);
401 let s = sparkline.render_to_string();
402 assert_eq!(s.chars().count(), 3);
404 }
405
406 #[test]
409 fn render_empty_area() {
410 let data = vec![1.0, 2.0, 3.0];
411 let sparkline = Sparkline::new(&data);
412 let area = Rect::new(0, 0, 0, 0);
413 let mut pool = GraphemePool::new();
414 let mut frame = Frame::new(1, 1, &mut pool);
415 Widget::render(&sparkline, area, &mut frame);
416 }
418
419 #[test]
420 fn render_basic() {
421 let data = vec![0.0, 0.5, 1.0];
422 let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
423 let area = Rect::new(0, 0, 3, 1);
424 let mut pool = GraphemePool::new();
425 let mut frame = Frame::new(3, 1, &mut pool);
426 Widget::render(&sparkline, area, &mut frame);
427
428 let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
429 let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
430 let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
431
432 assert_eq!(c0, Some(' ')); assert_eq!(c1, Some('▄')); assert_eq!(c2, Some('█')); }
436
437 #[test]
438 fn render_truncates_to_width() {
439 let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
440 let sparkline = Sparkline::new(&data);
441 let area = Rect::new(0, 0, 10, 1);
442 let mut pool = GraphemePool::new();
443 let mut frame = Frame::new(10, 1, &mut pool);
444 Widget::render(&sparkline, area, &mut frame);
445
446 for x in 0..10 {
448 let cell = frame.buffer.get(x, 0).unwrap();
449 assert!(cell.content.as_char().is_some());
450 }
451 }
452
453 #[test]
454 fn render_with_style() {
455 let data = vec![1.0];
456 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
457 let area = Rect::new(0, 0, 1, 1);
458 let mut pool = GraphemePool::new();
459 let mut frame = Frame::new(1, 1, &mut pool);
460 Widget::render(&sparkline, area, &mut frame);
461
462 let cell = frame.buffer.get(0, 0).unwrap();
463 assert_eq!(cell.fg, PackedRgba::GREEN);
464 }
465
466 #[test]
467 fn render_with_gradient() {
468 let data = vec![0.0, 0.5, 1.0];
469 let sparkline = Sparkline::new(&data)
470 .bounds(0.0, 1.0)
471 .gradient(PackedRgba::BLUE, PackedRgba::RED);
472 let area = Rect::new(0, 0, 3, 1);
473 let mut pool = GraphemePool::new();
474 let mut frame = Frame::new(3, 1, &mut pool);
475 Widget::render(&sparkline, area, &mut frame);
476
477 let c0 = frame.buffer.get(0, 0).unwrap();
478 let c2 = frame.buffer.get(2, 0).unwrap();
479
480 assert_eq!(c0.fg, PackedRgba::BLUE);
482 assert_eq!(c2.fg, PackedRgba::RED);
484 }
485
486 #[test]
489 fn degradation_skeleton_skips() {
490 use ftui_render::budget::DegradationLevel;
491
492 let data = vec![1.0, 2.0, 3.0];
493 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
494 let area = Rect::new(0, 0, 3, 1);
495 let mut pool = GraphemePool::new();
496 let mut frame = Frame::new(3, 1, &mut pool);
497 frame.buffer.degradation = DegradationLevel::Skeleton;
498 Widget::render(&sparkline, area, &mut frame);
499
500 for x in 0..3 {
502 assert!(
503 frame.buffer.get(x, 0).unwrap().is_empty(),
504 "cell at x={x} should be empty at Skeleton"
505 );
506 }
507 }
508
509 #[test]
510 fn degradation_no_styling_renders_without_color() {
511 use ftui_render::budget::DegradationLevel;
512
513 let data = vec![0.5];
514 let sparkline = Sparkline::new(&data)
515 .bounds(0.0, 1.0)
516 .style(Style::new().fg(PackedRgba::GREEN));
517 let area = Rect::new(0, 0, 1, 1);
518 let mut pool = GraphemePool::new();
519 let mut frame = Frame::new(1, 1, &mut pool);
520 frame.buffer.degradation = DegradationLevel::NoStyling;
521 Widget::render(&sparkline, area, &mut frame);
522
523 let cell = frame.buffer.get(0, 0).unwrap();
525 assert!(cell.content.as_char().is_some());
526 assert_ne!(cell.fg, PackedRgba::GREEN);
528 }
529
530 #[test]
531 fn render_shorter_data_clears_stale_suffix() {
532 let long = Sparkline::new(&[0.0, 0.5, 1.0, 0.75]).bounds(0.0, 1.0);
533 let short = Sparkline::new(&[1.0]);
534 let area = Rect::new(0, 0, 4, 1);
535 let mut pool = GraphemePool::new();
536 let mut frame = Frame::new(4, 1, &mut pool);
537
538 Widget::render(&long, area, &mut frame);
539 Widget::render(&short, area, &mut frame);
540
541 let row: String = (0..4)
542 .map(|x| {
543 frame
544 .buffer
545 .get(x, 0)
546 .and_then(|cell| cell.content.as_char())
547 .unwrap_or(' ')
548 })
549 .collect();
550 assert_eq!(row, "▄ ");
551 }
552
553 #[test]
554 fn render_empty_data_clears_stale_sparkline() {
555 let long = Sparkline::new(&[0.0, 0.5, 1.0]).bounds(0.0, 1.0);
556 let empty = Sparkline::new(&[]);
557 let area = Rect::new(0, 0, 3, 1);
558 let mut pool = GraphemePool::new();
559 let mut frame = Frame::new(3, 1, &mut pool);
560
561 Widget::render(&long, area, &mut frame);
562 Widget::render(&empty, area, &mut frame);
563
564 for x in 0..3 {
565 assert_eq!(
566 frame
567 .buffer
568 .get(x, 0)
569 .and_then(|cell| cell.content.as_char()),
570 Some(' ')
571 );
572 }
573 }
574
575 #[test]
578 fn lerp_color_endpoints() {
579 let low = PackedRgba::rgb(0, 0, 0);
580 let high = PackedRgba::rgb(255, 255, 255);
581
582 assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
583 assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
584 }
585
586 #[test]
587 fn lerp_color_midpoint() {
588 let low = PackedRgba::rgb(0, 0, 0);
589 let high = PackedRgba::rgb(255, 255, 255);
590 let mid = Sparkline::lerp_color(low, high, 0.5);
591
592 assert_eq!(mid.r(), 128);
593 assert_eq!(mid.g(), 128);
594 assert_eq!(mid.b(), 128);
595 }
596
597 #[test]
598 fn lerp_color_interpolates_alpha() {
599 let low = PackedRgba::rgba(0, 0, 0, 0);
600 let high = PackedRgba::rgba(255, 255, 255, 255);
601 let mid = Sparkline::lerp_color(low, high, 0.5);
602
603 assert_eq!(mid.r(), 128);
604 assert_eq!(mid.g(), 128);
605 assert_eq!(mid.b(), 128);
606 assert_eq!(mid.a(), 128);
607 }
608
609 #[test]
612 fn measure_empty_sparkline() {
613 let sparkline = Sparkline::new(&[]);
614 let c = sparkline.measure(Size::MAX);
615 assert_eq!(c, SizeConstraints::ZERO);
616 assert!(!sparkline.has_intrinsic_size());
617 }
618
619 #[test]
620 fn measure_single_value() {
621 let data = [5.0];
622 let sparkline = Sparkline::new(&data);
623 let c = sparkline.measure(Size::MAX);
624
625 assert_eq!(c.preferred.width, 1);
626 assert_eq!(c.preferred.height, 1);
627 assert!(sparkline.has_intrinsic_size());
628 }
629
630 #[test]
631 fn measure_multiple_values() {
632 let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
633 let sparkline = Sparkline::new(&data);
634 let c = sparkline.measure(Size::MAX);
635
636 assert_eq!(c.preferred.width, 50);
637 assert_eq!(c.preferred.height, 1);
638 assert_eq!(c.min.width, 1);
639 assert_eq!(c.min.height, 1);
640 }
641
642 #[test]
643 fn measure_max_equals_preferred() {
644 let data = [1.0, 2.0, 3.0];
645 let sparkline = Sparkline::new(&data);
646 let c = sparkline.measure(Size::MAX);
647
648 assert_eq!(c.max, Some(Size::new(3, 1)));
649 }
650}