sbom_tools/tui/widgets/
sparkline.rs1use crate::tui::theme::colors;
4use ratatui::{prelude::*, widgets::Widget};
5
6pub 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 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 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 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 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 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
121pub 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 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 const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
168
169 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
198pub 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 let pct_str = format!("{:.0}%", self.percentage);
228 let label = &self.label;
229
230 let center_y = area.y + area.height / 2;
232
233 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 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 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
281pub 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 let segment_width = if i == self.ecosystems.len() - 1 {
310 (area.x + area.width).saturating_sub(x) as usize
312 } else {
313 ((count * width) / total).max(1)
314 };
315
316 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 assert_eq!(bar.value, 50);
339 }
340}