Skip to main content

sbom_tools/tui/widgets/
sparkline.rs

1//! Sparkline and mini-chart widgets for statistics display.
2
3use crate::tui::theme::colors;
4use ratatui::{prelude::*, widgets::Widget};
5
6/// A simple horizontal bar chart for displaying counts.
7pub struct HorizontalBar {
8    label: String,
9    value: usize,
10    max_value: usize,
11    color: Color,
12    show_count: bool,
13}
14
15impl HorizontalBar {
16    pub fn new(label: impl Into<String>, value: usize, max_value: usize) -> Self {
17        Self {
18            label: label.into(),
19            value,
20            max_value,
21            color: colors().primary,
22            show_count: true,
23        }
24    }
25
26    pub fn color(mut self, color: Color) -> Self {
27        self.color = color;
28        self
29    }
30
31    pub fn show_count(mut self, show: bool) -> Self {
32        self.show_count = show;
33        self
34    }
35}
36
37impl Widget for HorizontalBar {
38    fn render(self, area: Rect, buf: &mut Buffer) {
39        if area.width < 10 || area.height < 1 {
40            return;
41        }
42
43        let label_width = 12.min(area.width as usize / 3);
44        let count_width = if self.show_count { 8 } else { 0 };
45        let bar_width = area.width as usize - label_width - count_width - 2;
46
47        let y = area.y;
48        let mut x = area.x;
49
50        // Render label
51        let label = if self.label.len() > label_width {
52            format!("{}...", &self.label[..label_width.saturating_sub(3)])
53        } else {
54            format!("{:width$}", self.label, width = label_width)
55        };
56
57        for ch in label.chars() {
58            if x < area.x + area.width {
59                if let Some(cell) = buf.cell_mut((x, y)) {
60                    cell.set_char(ch)
61                        .set_style(Style::default().fg(colors().text));
62                }
63                x += 1;
64            }
65        }
66
67        // Space
68        if x < area.x + area.width {
69            if let Some(cell) = buf.cell_mut((x, y)) {
70                cell.set_char(' ');
71            }
72            x += 1;
73        }
74
75        // Render bar
76        let filled = if self.max_value > 0 {
77            (self.value * bar_width) / self.max_value
78        } else {
79            0
80        };
81
82        for i in 0..bar_width {
83            if x < area.x + area.width {
84                let ch = if i < filled { '█' } else { '░' };
85                let style = if i < filled {
86                    Style::default().fg(self.color)
87                } else {
88                    Style::default().fg(colors().muted)
89                };
90                if let Some(cell) = buf.cell_mut((x, y)) {
91                    cell.set_char(ch).set_style(style);
92                }
93                x += 1;
94            }
95        }
96
97        // Space
98        if x < area.x + area.width {
99            if let Some(cell) = buf.cell_mut((x, y)) {
100                cell.set_char(' ');
101            }
102            x += 1;
103        }
104
105        // Render count
106        if self.show_count {
107            let count_str = format!("{:>6}", self.value);
108            for ch in count_str.chars() {
109                if x < area.x + area.width {
110                    if let Some(cell) = buf.cell_mut((x, y)) {
111                        cell.set_char(ch)
112                            .set_style(Style::default().fg(colors().primary).bold());
113                    }
114                    x += 1;
115                }
116            }
117        }
118    }
119}
120
121/// A mini sparkline for showing trends.
122pub struct MiniSparkline {
123    values: Vec<f64>,
124    color: Color,
125    baseline: f64,
126}
127
128impl MiniSparkline {
129    pub fn new(values: Vec<f64>) -> Self {
130        Self {
131            values,
132            color: colors().primary,
133            baseline: 0.0,
134        }
135    }
136
137    pub fn color(mut self, color: Color) -> Self {
138        self.color = color;
139        self
140    }
141
142    pub fn baseline(mut self, baseline: f64) -> Self {
143        self.baseline = baseline;
144        self
145    }
146}
147
148impl Widget for MiniSparkline {
149    fn render(self, area: Rect, buf: &mut Buffer) {
150        if area.width < 2 || area.height < 1 || self.values.is_empty() {
151            return;
152        }
153
154        let _height = area.height as usize;
155        let width = area.width as usize;
156
157        // Find min and max
158        let min_val = self.values.iter().cloned().fold(f64::INFINITY, f64::min);
159        let max_val = self
160            .values
161            .iter()
162            .cloned()
163            .fold(f64::NEG_INFINITY, f64::max);
164        let range = (max_val - min_val).max(1.0);
165
166        // Sparkline characters for sub-cell resolution
167        const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
168
169        // Sample values to fit width
170        let step = self.values.len() as f64 / width as f64;
171
172        for x in 0..width {
173            let idx = (x as f64 * step) as usize;
174            if idx < self.values.len() {
175                let val = self.values[idx];
176                let normalized = (val - min_val) / range;
177                let char_idx =
178                    ((normalized * (CHARS.len() - 1) as f64) as usize).min(CHARS.len() - 1);
179
180                let ch = CHARS[char_idx];
181                let color = if val > self.baseline {
182                    self.color
183                } else {
184                    colors().muted
185                };
186
187                if let Some(cell) =
188                    buf.cell_mut((area.x + x as u16, area.y + area.height - 1))
189                {
190                    cell.set_char(ch)
191                        .set_style(Style::default().fg(color));
192                }
193            }
194        }
195    }
196}
197
198/// A donut/pie-style percentage indicator.
199pub struct PercentageRing {
200    percentage: f64,
201    label: String,
202    color: Color,
203}
204
205impl PercentageRing {
206    pub fn new(percentage: f64, label: impl Into<String>) -> Self {
207        Self {
208            percentage: percentage.clamp(0.0, 100.0),
209            label: label.into(),
210            color: colors().primary,
211        }
212    }
213
214    pub fn color(mut self, color: Color) -> Self {
215        self.color = color;
216        self
217    }
218}
219
220impl Widget for PercentageRing {
221    fn render(self, area: Rect, buf: &mut Buffer) {
222        if area.width < 8 || area.height < 3 {
223            return;
224        }
225
226        // Simple text-based percentage display
227        let pct_str = format!("{:.0}%", self.percentage);
228        let label = &self.label;
229
230        // Center the display
231        let center_y = area.y + area.height / 2;
232
233        // Draw percentage
234        let pct_x = area.x + (area.width.saturating_sub(pct_str.len() as u16)) / 2;
235        for (i, ch) in pct_str.chars().enumerate() {
236            if pct_x + (i as u16) < area.x + area.width {
237                if let Some(cell) = buf.cell_mut((pct_x + i as u16, center_y)) {
238                    cell.set_char(ch)
239                        .set_style(Style::default().fg(self.color).bold());
240                }
241            }
242        }
243
244        // Draw label below
245        if center_y + 1 < area.y + area.height {
246            let label_x = area.x + (area.width.saturating_sub(label.len() as u16)) / 2;
247            for (i, ch) in label.chars().enumerate() {
248                if label_x + (i as u16) < area.x + area.width {
249                    if let Some(cell) = buf.cell_mut((label_x + i as u16, center_y + 1)) {
250                        cell.set_char(ch)
251                            .set_style(Style::default().fg(colors().text_muted));
252                    }
253                }
254            }
255        }
256
257        // Draw a simple bar above
258        if center_y > area.y {
259            let bar_width = area.width.saturating_sub(4) as usize;
260            let filled = (self.percentage / 100.0 * bar_width as f64) as usize;
261            let bar_x = area.x + 2;
262
263            for i in 0..bar_width {
264                if bar_x + (i as u16) < area.x + area.width - 2 {
265                    let ch = if i < filled { '█' } else { '░' };
266                    let color = if i < filled {
267                        self.color
268                    } else {
269                        colors().muted
270                    };
271                    if let Some(cell) = buf.cell_mut((bar_x + i as u16, center_y - 1)) {
272                        cell.set_char(ch)
273                            .set_style(Style::default().fg(color));
274                    }
275                }
276            }
277        }
278    }
279}
280
281/// Ecosystem distribution bar showing relative sizes.
282pub struct EcosystemBar {
283    pub ecosystems: Vec<(String, usize, Color)>,
284}
285
286impl EcosystemBar {
287    pub fn new(ecosystems: Vec<(String, usize, Color)>) -> Self {
288        Self { ecosystems }
289    }
290}
291
292impl Widget for EcosystemBar {
293    fn render(self, area: Rect, buf: &mut Buffer) {
294        if area.width < 10 || area.height < 1 || self.ecosystems.is_empty() {
295            return;
296        }
297
298        let total: usize = self.ecosystems.iter().map(|(_, count, _)| count).sum();
299        if total == 0 {
300            return;
301        }
302
303        let width = area.width as usize;
304        let mut x = area.x;
305        let y = area.y;
306
307        for (i, (_name, count, color)) in self.ecosystems.iter().enumerate() {
308            // Calculate width for this segment
309            let segment_width = if i == self.ecosystems.len() - 1 {
310                // Last segment gets remaining space
311                (area.x + area.width).saturating_sub(x) as usize
312            } else {
313                ((count * width) / total).max(1)
314            };
315
316            // Draw segment
317            for _j in 0..segment_width {
318                if x < area.x + area.width {
319                    if let Some(cell) = buf.cell_mut((x, y)) {
320                        cell.set_char('█')
321                            .set_style(Style::default().fg(*color));
322                    }
323                    x += 1;
324                }
325            }
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_horizontal_bar() {
336        let bar = HorizontalBar::new("Test", 50, 100).color(Color::Green);
337        // Just ensure it doesn't panic
338        assert_eq!(bar.value, 50);
339    }
340}