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 let range = max - min;
155 if range <= 0.0 {
156 return 4; }
158
159 let normalized = (value - min) / range;
160 let clamped = normalized.clamp(0.0, 1.0);
161 (clamped * 8.0).round() as usize
163 }
164
165 fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
167 let t = t.clamp(0.0, 1.0) as f32;
168 let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
169 let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
170 let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
171 PackedRgba::rgb(r, g, b)
172 }
173
174 pub fn render_to_string(&self) -> String {
176 if self.data.is_empty() {
177 return String::new();
178 }
179
180 let (min, max) = self.compute_bounds();
181 self.data
182 .iter()
183 .map(|&v| {
184 let idx = self.value_to_bar_index(v, min, max);
185 SPARK_CHARS[idx]
186 })
187 .collect()
188 }
189}
190
191impl Default for Sparkline<'_> {
192 fn default() -> Self {
193 Self::new(&[])
194 }
195}
196
197impl Widget for Sparkline<'_> {
198 fn render(&self, area: Rect, frame: &mut Frame) {
199 #[cfg(feature = "tracing")]
200 let _span = tracing::debug_span!(
201 "widget_render",
202 widget = "Sparkline",
203 x = area.x,
204 y = area.y,
205 w = area.width,
206 h = area.height,
207 data_len = self.data.len()
208 )
209 .entered();
210
211 if area.is_empty() || self.data.is_empty() {
212 return;
213 }
214
215 let deg = frame.buffer.degradation;
216
217 if !deg.render_content() {
219 return;
220 }
221
222 let (min, max) = self.compute_bounds();
223 let range = max - min;
224
225 let display_count = (area.width as usize).min(self.data.len());
227
228 for (i, &value) in self.data.iter().take(display_count).enumerate() {
229 let x = area.x + i as u16;
230 let y = area.y;
231
232 if x >= area.right() {
233 break;
234 }
235
236 let bar_idx = self.value_to_bar_index(value, min, max);
237 let ch = SPARK_CHARS[bar_idx];
238
239 let mut cell = Cell::from_char(ch);
240
241 if deg.apply_styling() {
243 crate::apply_style(&mut cell, self.style);
245
246 if let Some((low_color, high_color)) = self.gradient {
248 let t = if range > 0.0 {
249 (value - min) / range
250 } else {
251 0.5
252 };
253 cell.fg = Self::lerp_color(low_color, high_color, t);
254 } else if self.style.fg.is_none() {
255 cell.fg = PackedRgba::WHITE;
257 }
258 }
259
260 frame.buffer.set_fast(x, y, cell);
261 }
262 }
263}
264
265impl MeasurableWidget for Sparkline<'_> {
266 fn measure(&self, _available: Size) -> SizeConstraints {
267 if self.data.is_empty() {
268 return SizeConstraints::ZERO;
269 }
270
271 let width = self.data.len() as u16;
274
275 SizeConstraints {
276 min: Size::new(1, 1), preferred: Size::new(width, 1),
278 max: Some(Size::new(width, 1)), }
280 }
281
282 fn has_intrinsic_size(&self) -> bool {
283 !self.data.is_empty()
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use ftui_render::grapheme_pool::GraphemePool;
291
292 #[test]
295 fn empty_data() {
296 let sparkline = Sparkline::new(&[]);
297 assert_eq!(sparkline.render_to_string(), "");
298 }
299
300 #[test]
301 fn single_value() {
302 let sparkline = Sparkline::new(&[5.0]);
303 let s = sparkline.render_to_string();
305 assert_eq!(s.chars().count(), 1);
306 }
307
308 #[test]
309 fn constant_values() {
310 let data = vec![5.0, 5.0, 5.0, 5.0];
311 let sparkline = Sparkline::new(&data);
312 let s = sparkline.render_to_string();
313 assert_eq!(s.chars().count(), 4);
315 assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
316 }
317
318 #[test]
319 fn ascending_values() {
320 let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
321 let sparkline = Sparkline::new(&data);
322 let s = sparkline.render_to_string();
323 let chars: Vec<char> = s.chars().collect();
324 assert_eq!(chars[0], ' ');
326 assert_eq!(chars[8], '█');
327 }
328
329 #[test]
330 fn descending_values() {
331 let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
332 let sparkline = Sparkline::new(&data);
333 let s = sparkline.render_to_string();
334 let chars: Vec<char> = s.chars().collect();
335 assert_eq!(chars[0], '█');
337 assert_eq!(chars[8], ' ');
338 }
339
340 #[test]
341 fn explicit_bounds() {
342 let data = vec![5.0, 5.0, 5.0];
343 let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
344 let s = sparkline.render_to_string();
345 let chars: Vec<char> = s.chars().collect();
347 assert_eq!(chars[0], '▄');
348 }
349
350 #[test]
351 fn min_max_explicit() {
352 let data = vec![0.0, 50.0, 100.0];
353 let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
354 let s = sparkline.render_to_string();
355 let chars: Vec<char> = s.chars().collect();
356 assert_eq!(chars[0], ' '); assert_eq!(chars[1], '▄'); assert_eq!(chars[2], '█'); }
360
361 #[test]
362 fn negative_values() {
363 let data = vec![-10.0, 0.0, 10.0];
364 let sparkline = Sparkline::new(&data);
365 let s = sparkline.render_to_string();
366 let chars: Vec<char> = s.chars().collect();
367 assert_eq!(chars[0], ' '); assert_eq!(chars[2], '█'); }
370
371 #[test]
372 fn nan_values_handled() {
373 let data = vec![1.0, f64::NAN, 3.0];
374 let sparkline = Sparkline::new(&data);
375 let s = sparkline.render_to_string();
376 let chars: Vec<char> = s.chars().collect();
378 assert_eq!(chars[1], ' ');
379 }
380
381 #[test]
382 fn infinity_values_handled() {
383 let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
384 let sparkline = Sparkline::new(&data);
385 let s = sparkline.render_to_string();
386 assert_eq!(s.chars().count(), 3);
388 }
389
390 #[test]
393 fn render_empty_area() {
394 let data = vec![1.0, 2.0, 3.0];
395 let sparkline = Sparkline::new(&data);
396 let area = Rect::new(0, 0, 0, 0);
397 let mut pool = GraphemePool::new();
398 let mut frame = Frame::new(1, 1, &mut pool);
399 Widget::render(&sparkline, area, &mut frame);
400 }
402
403 #[test]
404 fn render_basic() {
405 let data = vec![0.0, 0.5, 1.0];
406 let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
407 let area = Rect::new(0, 0, 3, 1);
408 let mut pool = GraphemePool::new();
409 let mut frame = Frame::new(3, 1, &mut pool);
410 Widget::render(&sparkline, area, &mut frame);
411
412 let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
413 let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
414 let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
415
416 assert_eq!(c0, Some(' ')); assert_eq!(c1, Some('▄')); assert_eq!(c2, Some('█')); }
420
421 #[test]
422 fn render_truncates_to_width() {
423 let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
424 let sparkline = Sparkline::new(&data);
425 let area = Rect::new(0, 0, 10, 1);
426 let mut pool = GraphemePool::new();
427 let mut frame = Frame::new(10, 1, &mut pool);
428 Widget::render(&sparkline, area, &mut frame);
429
430 for x in 0..10 {
432 let cell = frame.buffer.get(x, 0).unwrap();
433 assert!(cell.content.as_char().is_some());
434 }
435 }
436
437 #[test]
438 fn render_with_style() {
439 let data = vec![1.0];
440 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
441 let area = Rect::new(0, 0, 1, 1);
442 let mut pool = GraphemePool::new();
443 let mut frame = Frame::new(1, 1, &mut pool);
444 Widget::render(&sparkline, area, &mut frame);
445
446 let cell = frame.buffer.get(0, 0).unwrap();
447 assert_eq!(cell.fg, PackedRgba::GREEN);
448 }
449
450 #[test]
451 fn render_with_gradient() {
452 let data = vec![0.0, 0.5, 1.0];
453 let sparkline = Sparkline::new(&data)
454 .bounds(0.0, 1.0)
455 .gradient(PackedRgba::BLUE, PackedRgba::RED);
456 let area = Rect::new(0, 0, 3, 1);
457 let mut pool = GraphemePool::new();
458 let mut frame = Frame::new(3, 1, &mut pool);
459 Widget::render(&sparkline, area, &mut frame);
460
461 let c0 = frame.buffer.get(0, 0).unwrap();
462 let c2 = frame.buffer.get(2, 0).unwrap();
463
464 assert_eq!(c0.fg, PackedRgba::BLUE);
466 assert_eq!(c2.fg, PackedRgba::RED);
468 }
469
470 #[test]
473 fn degradation_skeleton_skips() {
474 use ftui_render::budget::DegradationLevel;
475
476 let data = vec![1.0, 2.0, 3.0];
477 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
478 let area = Rect::new(0, 0, 3, 1);
479 let mut pool = GraphemePool::new();
480 let mut frame = Frame::new(3, 1, &mut pool);
481 frame.buffer.degradation = DegradationLevel::Skeleton;
482 Widget::render(&sparkline, area, &mut frame);
483
484 for x in 0..3 {
486 assert!(
487 frame.buffer.get(x, 0).unwrap().is_empty(),
488 "cell at x={x} should be empty at Skeleton"
489 );
490 }
491 }
492
493 #[test]
494 fn degradation_no_styling_renders_without_color() {
495 use ftui_render::budget::DegradationLevel;
496
497 let data = vec![0.5];
498 let sparkline = Sparkline::new(&data)
499 .bounds(0.0, 1.0)
500 .style(Style::new().fg(PackedRgba::GREEN));
501 let area = Rect::new(0, 0, 1, 1);
502 let mut pool = GraphemePool::new();
503 let mut frame = Frame::new(1, 1, &mut pool);
504 frame.buffer.degradation = DegradationLevel::NoStyling;
505 Widget::render(&sparkline, area, &mut frame);
506
507 let cell = frame.buffer.get(0, 0).unwrap();
509 assert!(cell.content.as_char().is_some());
510 assert_ne!(cell.fg, PackedRgba::GREEN);
512 }
513
514 #[test]
517 fn lerp_color_endpoints() {
518 let low = PackedRgba::rgb(0, 0, 0);
519 let high = PackedRgba::rgb(255, 255, 255);
520
521 assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
522 assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
523 }
524
525 #[test]
526 fn lerp_color_midpoint() {
527 let low = PackedRgba::rgb(0, 0, 0);
528 let high = PackedRgba::rgb(255, 255, 255);
529 let mid = Sparkline::lerp_color(low, high, 0.5);
530
531 assert_eq!(mid.r(), 128);
532 assert_eq!(mid.g(), 128);
533 assert_eq!(mid.b(), 128);
534 }
535
536 #[test]
539 fn measure_empty_sparkline() {
540 let sparkline = Sparkline::new(&[]);
541 let c = sparkline.measure(Size::MAX);
542 assert_eq!(c, SizeConstraints::ZERO);
543 assert!(!sparkline.has_intrinsic_size());
544 }
545
546 #[test]
547 fn measure_single_value() {
548 let data = [5.0];
549 let sparkline = Sparkline::new(&data);
550 let c = sparkline.measure(Size::MAX);
551
552 assert_eq!(c.preferred.width, 1);
553 assert_eq!(c.preferred.height, 1);
554 assert!(sparkline.has_intrinsic_size());
555 }
556
557 #[test]
558 fn measure_multiple_values() {
559 let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
560 let sparkline = Sparkline::new(&data);
561 let c = sparkline.measure(Size::MAX);
562
563 assert_eq!(c.preferred.width, 50);
564 assert_eq!(c.preferred.height, 1);
565 assert_eq!(c.min.width, 1);
566 assert_eq!(c.min.height, 1);
567 }
568
569 #[test]
570 fn measure_max_equals_preferred() {
571 let data = [1.0, 2.0, 3.0];
572 let sparkline = Sparkline::new(&data);
573 let c = sparkline.measure(Size::MAX);
574
575 assert_eq!(c.max, Some(Size::new(3, 1)));
576 }
577}