1use crate::common;
10use crate::terminal;
11use crate::theme::Colors;
12use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
13use owo_colors::OwoColorize;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18const SPARKLINE_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
19const SPARKLINE_LEN: usize = 12;
20const MIN_BAR_WIDTH: usize = 20;
21
22fn adaptive_bar_width() -> usize {
23 let term_w = common::get_terminal_width().unwrap_or(100) as usize;
24 let available = term_w.saturating_sub(52);
25 available.clamp(MIN_BAR_WIDTH, 50)
26}
27
28fn speed_color(speed_mbps: f64, theme: crate::theme::Theme) -> String {
29 if speed_mbps >= 100.0 {
30 Colors::good(&format!("{speed_mbps:.1} Mb/s"), theme)
31 } else if speed_mbps >= 25.0 {
32 Colors::info(&format!("{speed_mbps:.1} Mb/s"), theme)
33 } else if speed_mbps >= 5.0 {
34 Colors::warn(&format!("{speed_mbps:.1} Mb/s"), theme)
35 } else {
36 Colors::bad(&format!("{speed_mbps:.1} Mb/s"), theme)
37 }
38}
39
40fn speed_trend(samples: &[f64]) -> &'static str {
41 if samples.len() < 6 {
42 return "→";
43 }
44 let n = samples.len();
45 let recent_count = 2;
46 let older_count = 3;
47 let recent_avg: f64 =
48 samples[n - recent_count..].iter().copied().sum::<f64>() / recent_count as f64;
49 let older_avg: f64 = samples[n - recent_count - older_count..n - recent_count]
50 .iter()
51 .copied()
52 .sum::<f64>()
53 / older_count as f64;
54 let ratio = recent_avg / older_avg.max(0.01);
55 if ratio > 1.05 {
56 "↑"
57 } else if ratio < 0.95 {
58 "↓"
59 } else {
60 "→"
61 }
62}
63
64fn render_sparkline(samples: &[f64]) -> String {
65 if samples.len() < 2 {
66 return String::new();
67 }
68 let min = samples.iter().cloned().reduce(f64::min).unwrap_or(0.0);
69 let max = samples.iter().cloned().reduce(f64::max).unwrap_or(1.0);
70 let range = max - min;
71 if range < 0.001 {
72 return SPARKLINE_CHARS[4].to_string().repeat(SPARKLINE_LEN);
73 }
74 let step = if samples.len() > SPARKLINE_LEN {
75 samples.len() / SPARKLINE_LEN
76 } else {
77 1
78 };
79 let sampled: Vec<f64> = (0..SPARKLINE_LEN)
80 .map(|i| {
81 let idx = ((i * step) + (step / 2)).min(samples.len() - 1);
82 samples[idx]
83 })
84 .collect();
85 sampled
86 .iter()
87 .map(|s| {
88 let norm = ((s - min) / range).clamp(0.0, 1.0);
89 let idx = (norm * (SPARKLINE_CHARS.len() - 1) as f64).round() as usize;
90 SPARKLINE_CHARS[idx.clamp(0, SPARKLINE_CHARS.len() - 1)]
91 })
92 .collect()
93}
94
95pub struct Tracker {
98 bar: ProgressBar,
99 done: Arc<AtomicBool>,
100 speed_samples: Mutex<Vec<f64>>,
101}
102
103unsafe impl Send for Tracker {}
106unsafe impl Sync for Tracker {}
107
108impl Tracker {
109 #[must_use]
110 pub fn new(label: &str) -> Self {
111 Self::with_target(label, ProgressDrawTarget::stderr_with_hz(10))
112 }
113
114 #[must_use]
115 pub fn new_animated(label: &str) -> Self {
116 if terminal::no_animation() {
117 return Self::new(label);
118 }
119 Self::with_target_animated(label, ProgressDrawTarget::stderr_with_hz(10))
120 }
121
122 #[must_use]
123 pub fn with_target(label: &str, target: ProgressDrawTarget) -> Self {
124 let done = Arc::new(AtomicBool::new(false));
125 let bar = ProgressBar::with_draw_target(Some(100), target);
126 let bw = adaptive_bar_width();
127
128 let tmpl = format!(
129 " {{prefix}} {{bar:{bw}.cyan/blue}} {{percent:>3}}% {{elapsed_precise}} | {{msg}}"
130 );
131 let style = ProgressStyle::with_template(&tmpl)
132 .unwrap()
133 .progress_chars("━░─");
134
135 bar.set_style(style);
136 let arrow = if label.starts_with('D') {
137 "↓ "
138 } else if label.starts_with('U') {
139 "↑ "
140 } else {
141 " "
142 };
143 bar.set_prefix(if terminal::no_color() {
144 format!("{:<12}", format!("{arrow}{label}:"))
145 } else {
146 format!("{:<12}", format!("{arrow}{label}:").dimmed())
147 });
148 bar.set_message("starting...");
149 bar.set_position(0);
150
151 Self {
152 bar,
153 done,
154 speed_samples: Mutex::new(Vec::new()),
155 }
156 }
157
158 fn with_target_animated(label: &str, target: ProgressDrawTarget) -> Self {
159 let done = Arc::new(AtomicBool::new(false));
160 let bar = ProgressBar::with_draw_target(Some(100), target);
161 let bw = adaptive_bar_width();
162
163 let tmpl = format!(
164 " {{prefix}} {{spinner}} {{bar:{bw}.cyan/blue}} {{percent:>3}}% {{elapsed_precise}} | {{msg}}"
165 );
166 let style = ProgressStyle::with_template(&tmpl)
167 .unwrap()
168 .progress_chars("━░─")
169 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠏"]);
170
171 bar.set_style(style);
172 let arrow = if label.starts_with('D') {
173 "↓ "
174 } else if label.starts_with('U') {
175 "↑ "
176 } else {
177 " "
178 };
179 bar.set_prefix(if terminal::no_color() {
180 format!("{:<12}", format!("{arrow}{label}:"))
181 } else {
182 format!("{:<12}", format!("{arrow}{label}:").dimmed())
183 });
184 bar.set_message("starting...");
185 bar.set_position(0);
186 bar.enable_steady_tick(Duration::from_millis(100));
187
188 Self {
189 bar,
190 done,
191 speed_samples: Mutex::new(Vec::new()),
192 }
193 }
194
195 pub fn update(&self, speed_mbps: f64, progress: f64, bytes: u64) {
196 {
197 let mut samples = self.speed_samples.lock().unwrap();
198 samples.push(speed_mbps);
199 if samples.len() > 60 {
200 let drain = samples.len() - 60;
201 samples.drain(..drain);
202 }
203 }
204
205 let data_str = common::format_data_size(bytes);
206
207 let msg = if terminal::no_color() {
208 let speed_str = format!("{speed_mbps:.1} Mb/s");
209 format!("{data_str} @ {speed_str}")
210 } else {
211 let speed_colored = speed_color(speed_mbps, crate::theme::Theme::Dark);
212 let samples = self.speed_samples.lock().unwrap();
213 let sparkline = render_sparkline(&samples);
214 let trend = speed_trend(&samples);
215 if sparkline.is_empty() {
216 format!("{} @ {}", data_str.white(), speed_colored)
217 } else {
218 format!(
219 "{} {} {} @ {}",
220 data_str.white(),
221 sparkline.dimmed(),
222 trend,
223 speed_colored
224 )
225 }
226 };
227
228 self.bar.set_message(msg);
229 let pct = (progress * 100.0).clamp(0.0, u64::MAX as f64) as u64;
230 self.bar.set_position(pct.min(100));
231 }
232
233 pub fn finish(&self, final_speed_mbps: f64, total_bytes: u64, theme: crate::theme::Theme) {
234 let speed_str = if final_speed_mbps < 1000.0 {
235 format!("{final_speed_mbps:.2} Mb/s")
236 } else {
237 format!("{:.2} Gb/s", final_speed_mbps / 1000.0)
238 };
239
240 let data_str = common::format_data_size(total_bytes);
241
242 self.bar.set_position(100);
243 let msg = if terminal::no_color() {
244 format!("DONE ({data_str} total @ {speed_str})")
245 } else {
246 format!(
247 "{} ({} total @ {})",
248 Colors::good("DONE", theme),
249 data_str.dimmed(),
250 Colors::good(&speed_str, theme)
251 )
252 };
253 self.bar.finish_with_message(msg);
254 self.done.store(true, Ordering::Relaxed);
255 }
256}
257
258#[must_use]
259pub fn create_spinner(message: &str) -> ProgressBar {
260 let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(10));
261 pb.set_style(
262 ProgressStyle::with_template(" {spinner} {msg}")
263 .unwrap()
264 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠏"]),
265 );
266 pb.set_message(message.to_string());
267 pb.enable_steady_tick(std::time::Duration::from_millis(120));
268 pb
269}
270
271pub fn finish_ok(pb: &ProgressBar, message: &str, theme: crate::theme::Theme) {
272 if terminal::no_color() {
273 pb.finish_with_message(format!(" {message}"));
274 } else {
275 pb.finish_with_message(format!(" {} {}", Colors::good("◉", theme), message));
276 }
277}
278
279pub fn reveal_grade(label: &str, grade_str: &str, grade_plain: &str, nc: bool) {
282 if nc {
283 std::thread::sleep(Duration::from_millis(300));
284 eprintln!(" {label} → {grade_plain}");
285 } else {
286 let spinner = create_spinner(&format!("Computing {label}..."));
287 std::thread::sleep(Duration::from_millis(400));
288 spinner.finish_and_clear();
289 eprintln!(" {label} → {grade_str}");
290 }
291}
292
293pub fn reveal_scan_complete(
294 sample_count: usize,
295 grade_badge: &str,
296 grade_plain: &str,
297 nc: bool,
298 theme: crate::theme::Theme,
299) {
300 if terminal::no_animation() {
301 eprintln!(" ◉ Scanned {sample_count} samples {grade_plain}");
302 } else if nc {
303 std::thread::sleep(Duration::from_millis(100));
304 eprintln!(" ◉ Scanned {sample_count} samples Grade: {grade_plain}");
305 } else {
306 std::thread::sleep(Duration::from_millis(100));
307 eprintln!(
308 " {} Scanned {} samples {}",
309 Colors::good("◉", theme),
310 Colors::bold(&sample_count.to_string(), theme),
311 grade_badge,
312 );
313 }
314}
315
316pub fn reveal_pause() {
317 if terminal::no_animation() {
318 return;
319 }
320 std::thread::sleep(Duration::from_millis(40));
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use serial_test::serial;
327
328 fn set_no_color() {
329 #[allow(unsafe_code)]
330 unsafe {
331 std::env::set_var("NO_COLOR", "1");
332 }
333 }
334
335 fn unset_no_color() {
336 #[allow(unsafe_code)]
337 unsafe {
338 std::env::remove_var("NO_COLOR");
339 }
340 }
341
342 #[test]
343 fn test_no_color_default() {
344 let _ = terminal::no_color();
345 }
346
347 #[test]
348 fn test_create_spinner() {
349 let pb = create_spinner("Testing...");
350 assert!(!pb.is_finished());
351 pb.finish_and_clear();
352 }
353
354 #[test]
355 fn test_finish_ok() {
356 let pb = create_spinner("Testing...");
357 finish_ok(&pb, "Done", crate::theme::Theme::Dark);
358 assert!(pb.is_finished());
359 }
360
361 #[test]
362 fn test_speed_progress_new() {
363 let sp = Tracker::new("Download");
364 assert!(!sp.done.load(Ordering::Relaxed));
365 sp.bar.finish_and_clear();
366 }
367
368 #[test]
369 fn test_speed_progress_update() {
370 let sp = Tracker::new("Download");
371 sp.update(150.5, 0.5, 1024 * 1024);
372 assert_eq!(sp.bar.position(), 50);
373 sp.bar.finish_and_clear();
374 }
375
376 #[test]
377 fn test_speed_progress_with_sparkline() {
378 let sp = Tracker::new("Download");
379 for i in 1..=20 {
380 sp.update(50.0 + i as f64 * 2.0, i as f64 / 20.0, 1024 * 1024);
381 }
382 let samples = sp.speed_samples.lock().unwrap();
383 assert_eq!(samples.len(), 20);
384 let sparkline = render_sparkline(&samples);
385 assert!(!sparkline.is_empty());
386 assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
387 sp.bar.finish_and_clear();
388 }
389
390 #[test]
391 fn test_speed_progress_nc() {
392 set_no_color();
393 let sp = Tracker::new("Upload");
394 sp.update(50.0, 0.25, 512 * 1024);
395 assert_eq!(sp.bar.position(), 25);
396 sp.finish(50.0, 1024 * 1024, crate::theme::Theme::Dark);
397 assert!(sp.done.load(Ordering::Relaxed));
398 unset_no_color();
399 }
400
401 #[test]
402 fn test_speed_trend_up() {
403 let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
404 assert_eq!(speed_trend(&samples), "↑");
405 }
406
407 #[test]
408 fn test_speed_trend_down() {
409 let samples = vec![60.0, 50.0, 40.0, 30.0, 20.0, 10.0];
410 assert_eq!(speed_trend(&samples), "↓");
411 }
412
413 #[test]
414 fn test_speed_trend_stable() {
415 let samples = vec![50.0, 51.0, 49.0, 50.0, 51.0, 50.0];
416 assert_eq!(speed_trend(&samples), "→");
417 }
418
419 #[test]
420 fn test_speed_trend_few_samples() {
421 assert_eq!(speed_trend(&[10.0, 20.0]), "→");
422 }
423
424 #[test]
425 fn test_render_sparkline_basic() {
426 let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
427 let sparkline = render_sparkline(&samples);
428 assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
429 }
430
431 #[test]
432 fn test_render_sparkline_flat() {
433 let samples = vec![50.0; 10];
434 let sparkline = render_sparkline(&samples);
435 assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
436 let chars: Vec<char> = sparkline.chars().collect();
437 assert!(chars.windows(2).all(|w| w[0] == w[1]));
438 }
439
440 #[test]
441 fn test_render_sparkline_empty() {
442 let sparkline = render_sparkline(&[]);
443 assert!(sparkline.is_empty());
444 }
445
446 #[test]
447 fn test_render_sparkline_single() {
448 let sparkline = render_sparkline(&[42.0]);
449 assert!(sparkline.is_empty());
450 }
451
452 #[test]
453 fn test_speed_color_good() {
454 let colored = speed_color(150.0, crate::theme::Theme::Dark);
455 assert!(colored.contains("150.0"));
456 }
457
458 #[test]
459 fn test_speed_color_warn() {
460 let colored = speed_color(10.0, crate::theme::Theme::Dark);
461 assert!(colored.contains("10.0"));
462 }
463
464 #[test]
465 fn test_speed_color_bad() {
466 let colored = speed_color(2.0, crate::theme::Theme::Dark);
467 assert!(colored.contains("2.0"));
468 }
469
470 #[test]
471 fn test_adaptive_bar_width() {
472 let w = adaptive_bar_width();
473 assert!(w >= MIN_BAR_WIDTH);
474 assert!(w <= 50);
475 }
476
477 #[test]
478 #[serial]
479 fn test_no_color_env_set() {
480 set_no_color();
481 assert!(terminal::no_color());
482 unset_no_color();
483 }
484
485 #[test]
486 #[serial]
487 fn test_create_spinner_nc() {
488 set_no_color();
489 let pb = create_spinner("Testing...");
490 assert!(!pb.is_finished());
491 pb.finish_and_clear();
492 unset_no_color();
493 }
494
495 #[test]
496 #[serial]
497 fn test_finish_ok_nc() {
498 set_no_color();
499 let pb = create_spinner("Testing...");
500 finish_ok(&pb, "Done", crate::theme::Theme::Dark);
501 assert!(pb.is_finished());
502 unset_no_color();
503 }
504
505 #[test]
506 #[serial]
507 fn test_reveal_grade_nc() {
508 set_no_color();
509 reveal_grade("Overall", "A", "A", true);
510 unset_no_color();
511 }
512
513 #[test]
514 #[serial]
515 fn test_reveal_scan_complete_nc() {
516 set_no_color();
517 reveal_scan_complete(42, "B+", "B+", true, crate::theme::Theme::Dark);
518 unset_no_color();
519 }
520
521 #[test]
522 fn test_reveal_pause() {
523 reveal_pause();
524 }
525}