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 pub fn new(data: &'a [f64]) -> Self {
63 Self {
64 data,
65 min: None,
66 max: None,
67 style: Style::default(),
68 gradient: None,
69 baseline: 0.0,
70 }
71 }
72
73 pub fn min(mut self, min: f64) -> Self {
77 self.min = Some(min);
78 self
79 }
80
81 pub fn max(mut self, max: f64) -> Self {
85 self.max = Some(max);
86 self
87 }
88
89 pub fn bounds(mut self, min: f64, max: f64) -> Self {
91 self.min = Some(min);
92 self.max = Some(max);
93 self
94 }
95
96 pub fn style(mut self, style: Style) -> Self {
98 self.style = style;
99 self
100 }
101
102 pub fn gradient(mut self, low_color: PackedRgba, high_color: PackedRgba) -> Self {
107 self.gradient = Some((low_color, high_color));
108 self
109 }
110
111 pub fn baseline(mut self, baseline: f64) -> Self {
116 self.baseline = baseline;
117 self
118 }
119
120 fn compute_bounds(&self) -> (f64, f64) {
122 let data_min = self
123 .min
124 .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
125 let data_max = self
126 .max
127 .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
128
129 let min = if data_min.is_finite() { data_min } else { 0.0 };
131 let max = if data_max.is_finite() { data_max } else { 1.0 };
132
133 if min >= max {
134 (min - 0.5, max + 0.5)
136 } else {
137 (min, max)
138 }
139 }
140
141 fn value_to_bar_index(&self, value: f64, min: f64, max: f64) -> usize {
143 if !value.is_finite() {
144 return 0;
145 }
146
147 let range = max - min;
148 if range <= 0.0 {
149 return 4; }
151
152 let normalized = (value - min) / range;
153 let clamped = normalized.clamp(0.0, 1.0);
154 (clamped * 8.0).round() as usize
156 }
157
158 fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
160 let t = t.clamp(0.0, 1.0) as f32;
161 let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
162 let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
163 let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
164 PackedRgba::rgb(r, g, b)
165 }
166
167 pub fn render_to_string(&self) -> String {
169 if self.data.is_empty() {
170 return String::new();
171 }
172
173 let (min, max) = self.compute_bounds();
174 self.data
175 .iter()
176 .map(|&v| {
177 let idx = self.value_to_bar_index(v, min, max);
178 SPARK_CHARS[idx]
179 })
180 .collect()
181 }
182}
183
184impl Default for Sparkline<'_> {
185 fn default() -> Self {
186 Self::new(&[])
187 }
188}
189
190impl Widget for Sparkline<'_> {
191 fn render(&self, area: Rect, frame: &mut Frame) {
192 #[cfg(feature = "tracing")]
193 let _span = tracing::debug_span!(
194 "widget_render",
195 widget = "Sparkline",
196 x = area.x,
197 y = area.y,
198 w = area.width,
199 h = area.height,
200 data_len = self.data.len()
201 )
202 .entered();
203
204 if area.is_empty() || self.data.is_empty() {
205 return;
206 }
207
208 let deg = frame.buffer.degradation;
209
210 if !deg.render_content() {
212 return;
213 }
214
215 let (min, max) = self.compute_bounds();
216 let range = max - min;
217
218 let display_count = (area.width as usize).min(self.data.len());
220
221 for (i, &value) in self.data.iter().take(display_count).enumerate() {
222 let x = area.x + i as u16;
223 let y = area.y;
224
225 if x >= area.right() {
226 break;
227 }
228
229 let bar_idx = self.value_to_bar_index(value, min, max);
230 let ch = SPARK_CHARS[bar_idx];
231
232 let mut cell = Cell::from_char(ch);
233
234 if deg.apply_styling() {
236 crate::apply_style(&mut cell, self.style);
238
239 if let Some((low_color, high_color)) = self.gradient {
241 let t = if range > 0.0 {
242 (value - min) / range
243 } else {
244 0.5
245 };
246 cell.fg = Self::lerp_color(low_color, high_color, t);
247 } else if self.style.fg.is_none() {
248 cell.fg = PackedRgba::WHITE;
250 }
251 }
252
253 frame.buffer.set(x, y, cell);
254 }
255 }
256}
257
258impl MeasurableWidget for Sparkline<'_> {
259 fn measure(&self, _available: Size) -> SizeConstraints {
260 if self.data.is_empty() {
261 return SizeConstraints::ZERO;
262 }
263
264 let width = self.data.len() as u16;
267
268 SizeConstraints {
269 min: Size::new(1, 1), preferred: Size::new(width, 1),
271 max: Some(Size::new(width, 1)), }
273 }
274
275 fn has_intrinsic_size(&self) -> bool {
276 !self.data.is_empty()
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use ftui_render::grapheme_pool::GraphemePool;
284
285 #[test]
288 fn empty_data() {
289 let sparkline = Sparkline::new(&[]);
290 assert_eq!(sparkline.render_to_string(), "");
291 }
292
293 #[test]
294 fn single_value() {
295 let sparkline = Sparkline::new(&[5.0]);
296 let s = sparkline.render_to_string();
298 assert_eq!(s.chars().count(), 1);
299 }
300
301 #[test]
302 fn constant_values() {
303 let data = vec![5.0, 5.0, 5.0, 5.0];
304 let sparkline = Sparkline::new(&data);
305 let s = sparkline.render_to_string();
306 assert_eq!(s.chars().count(), 4);
308 assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
309 }
310
311 #[test]
312 fn ascending_values() {
313 let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
314 let sparkline = Sparkline::new(&data);
315 let s = sparkline.render_to_string();
316 let chars: Vec<char> = s.chars().collect();
317 assert_eq!(chars[0], ' ');
319 assert_eq!(chars[8], '█');
320 }
321
322 #[test]
323 fn descending_values() {
324 let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
325 let sparkline = Sparkline::new(&data);
326 let s = sparkline.render_to_string();
327 let chars: Vec<char> = s.chars().collect();
328 assert_eq!(chars[0], '█');
330 assert_eq!(chars[8], ' ');
331 }
332
333 #[test]
334 fn explicit_bounds() {
335 let data = vec![5.0, 5.0, 5.0];
336 let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
337 let s = sparkline.render_to_string();
338 let chars: Vec<char> = s.chars().collect();
340 assert_eq!(chars[0], '▄');
341 }
342
343 #[test]
344 fn min_max_explicit() {
345 let data = vec![0.0, 50.0, 100.0];
346 let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
347 let s = sparkline.render_to_string();
348 let chars: Vec<char> = s.chars().collect();
349 assert_eq!(chars[0], ' '); assert_eq!(chars[1], '▄'); assert_eq!(chars[2], '█'); }
353
354 #[test]
355 fn negative_values() {
356 let data = vec![-10.0, 0.0, 10.0];
357 let sparkline = Sparkline::new(&data);
358 let s = sparkline.render_to_string();
359 let chars: Vec<char> = s.chars().collect();
360 assert_eq!(chars[0], ' '); assert_eq!(chars[2], '█'); }
363
364 #[test]
365 fn nan_values_handled() {
366 let data = vec![1.0, f64::NAN, 3.0];
367 let sparkline = Sparkline::new(&data);
368 let s = sparkline.render_to_string();
369 let chars: Vec<char> = s.chars().collect();
371 assert_eq!(chars[1], ' ');
372 }
373
374 #[test]
375 fn infinity_values_handled() {
376 let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
377 let sparkline = Sparkline::new(&data);
378 let s = sparkline.render_to_string();
379 assert_eq!(s.chars().count(), 3);
381 }
382
383 #[test]
386 fn render_empty_area() {
387 let data = vec![1.0, 2.0, 3.0];
388 let sparkline = Sparkline::new(&data);
389 let area = Rect::new(0, 0, 0, 0);
390 let mut pool = GraphemePool::new();
391 let mut frame = Frame::new(1, 1, &mut pool);
392 Widget::render(&sparkline, area, &mut frame);
393 }
395
396 #[test]
397 fn render_basic() {
398 let data = vec![0.0, 0.5, 1.0];
399 let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
400 let area = Rect::new(0, 0, 3, 1);
401 let mut pool = GraphemePool::new();
402 let mut frame = Frame::new(3, 1, &mut pool);
403 Widget::render(&sparkline, area, &mut frame);
404
405 let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
406 let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
407 let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
408
409 assert_eq!(c0, Some(' ')); assert_eq!(c1, Some('▄')); assert_eq!(c2, Some('█')); }
413
414 #[test]
415 fn render_truncates_to_width() {
416 let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
417 let sparkline = Sparkline::new(&data);
418 let area = Rect::new(0, 0, 10, 1);
419 let mut pool = GraphemePool::new();
420 let mut frame = Frame::new(10, 1, &mut pool);
421 Widget::render(&sparkline, area, &mut frame);
422
423 for x in 0..10 {
425 let cell = frame.buffer.get(x, 0).unwrap();
426 assert!(cell.content.as_char().is_some());
427 }
428 }
429
430 #[test]
431 fn render_with_style() {
432 let data = vec![1.0];
433 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
434 let area = Rect::new(0, 0, 1, 1);
435 let mut pool = GraphemePool::new();
436 let mut frame = Frame::new(1, 1, &mut pool);
437 Widget::render(&sparkline, area, &mut frame);
438
439 let cell = frame.buffer.get(0, 0).unwrap();
440 assert_eq!(cell.fg, PackedRgba::GREEN);
441 }
442
443 #[test]
444 fn render_with_gradient() {
445 let data = vec![0.0, 0.5, 1.0];
446 let sparkline = Sparkline::new(&data)
447 .bounds(0.0, 1.0)
448 .gradient(PackedRgba::BLUE, PackedRgba::RED);
449 let area = Rect::new(0, 0, 3, 1);
450 let mut pool = GraphemePool::new();
451 let mut frame = Frame::new(3, 1, &mut pool);
452 Widget::render(&sparkline, area, &mut frame);
453
454 let c0 = frame.buffer.get(0, 0).unwrap();
455 let c2 = frame.buffer.get(2, 0).unwrap();
456
457 assert_eq!(c0.fg, PackedRgba::BLUE);
459 assert_eq!(c2.fg, PackedRgba::RED);
461 }
462
463 #[test]
466 fn degradation_skeleton_skips() {
467 use ftui_render::budget::DegradationLevel;
468
469 let data = vec![1.0, 2.0, 3.0];
470 let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
471 let area = Rect::new(0, 0, 3, 1);
472 let mut pool = GraphemePool::new();
473 let mut frame = Frame::new(3, 1, &mut pool);
474 frame.buffer.degradation = DegradationLevel::Skeleton;
475 Widget::render(&sparkline, area, &mut frame);
476
477 for x in 0..3 {
479 assert!(
480 frame.buffer.get(x, 0).unwrap().is_empty(),
481 "cell at x={x} should be empty at Skeleton"
482 );
483 }
484 }
485
486 #[test]
487 fn degradation_no_styling_renders_without_color() {
488 use ftui_render::budget::DegradationLevel;
489
490 let data = vec![0.5];
491 let sparkline = Sparkline::new(&data)
492 .bounds(0.0, 1.0)
493 .style(Style::new().fg(PackedRgba::GREEN));
494 let area = Rect::new(0, 0, 1, 1);
495 let mut pool = GraphemePool::new();
496 let mut frame = Frame::new(1, 1, &mut pool);
497 frame.buffer.degradation = DegradationLevel::NoStyling;
498 Widget::render(&sparkline, area, &mut frame);
499
500 let cell = frame.buffer.get(0, 0).unwrap();
502 assert!(cell.content.as_char().is_some());
503 assert_ne!(cell.fg, PackedRgba::GREEN);
505 }
506
507 #[test]
510 fn lerp_color_endpoints() {
511 let low = PackedRgba::rgb(0, 0, 0);
512 let high = PackedRgba::rgb(255, 255, 255);
513
514 assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
515 assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
516 }
517
518 #[test]
519 fn lerp_color_midpoint() {
520 let low = PackedRgba::rgb(0, 0, 0);
521 let high = PackedRgba::rgb(255, 255, 255);
522 let mid = Sparkline::lerp_color(low, high, 0.5);
523
524 assert_eq!(mid.r(), 128);
525 assert_eq!(mid.g(), 128);
526 assert_eq!(mid.b(), 128);
527 }
528
529 #[test]
532 fn measure_empty_sparkline() {
533 let sparkline = Sparkline::new(&[]);
534 let c = sparkline.measure(Size::MAX);
535 assert_eq!(c, SizeConstraints::ZERO);
536 assert!(!sparkline.has_intrinsic_size());
537 }
538
539 #[test]
540 fn measure_single_value() {
541 let data = [5.0];
542 let sparkline = Sparkline::new(&data);
543 let c = sparkline.measure(Size::MAX);
544
545 assert_eq!(c.preferred.width, 1);
546 assert_eq!(c.preferred.height, 1);
547 assert!(sparkline.has_intrinsic_size());
548 }
549
550 #[test]
551 fn measure_multiple_values() {
552 let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
553 let sparkline = Sparkline::new(&data);
554 let c = sparkline.measure(Size::MAX);
555
556 assert_eq!(c.preferred.width, 50);
557 assert_eq!(c.preferred.height, 1);
558 assert_eq!(c.min.width, 1);
559 assert_eq!(c.min.height, 1);
560 }
561
562 #[test]
563 fn measure_max_equals_preferred() {
564 let data = [1.0, 2.0, 3.0];
565 let sparkline = Sparkline::new(&data);
566 let c = sparkline.measure(Size::MAX);
567
568 assert_eq!(c.max, Some(Size::new(3, 1)));
569 }
570}