oxigaf_cli/progress.rs
1//! Unified progress bar utilities with consistent styling.
2//!
3//! Provides reusable progress bar helpers for various CLI operations:
4//! - Training iterations
5//! - File downloads
6//! - Rendering frames
7//! - Export operations
8//! - Indeterminate spinners
9//! - Multi-progress for parallel operations
10//!
11//! All progress bars respect verbosity settings and use a consistent
12//! color scheme (green spinner, cyan/blue bar).
13
14use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
15
16use crate::verbosity::Verbosity;
17
18// ---------------------------------------------------------------------------
19// Progress Bar Helpers
20// ---------------------------------------------------------------------------
21
22/// Create a progress bar for training iterations.
23///
24/// Shows iteration count, loss value, and estimated time to completion.
25///
26/// # Arguments
27///
28/// * `total` - Total number of training iterations
29/// * `verbosity` - Verbosity level to respect
30///
31/// # Returns
32///
33/// A configured progress bar, or hidden bar if progress shouldn't be shown.
34///
35/// # Examples
36///
37/// ```no_run
38/// use oxigaf_cli::progress;
39/// use oxigaf_cli::verbosity::Verbosity;
40///
41/// let pb = progress::training_progress(1000, Verbosity::Normal);
42/// for i in 0..1000 {
43/// pb.set_message(format!("0.{:04}", 1000 - i));
44/// pb.inc(1);
45/// }
46/// pb.finish_with_message("Training complete");
47/// ```
48#[must_use]
49pub fn training_progress(total: u64, verbosity: Verbosity) -> ProgressBar {
50 if !verbosity.show_progress() {
51 return ProgressBar::hidden();
52 }
53
54 let pb = ProgressBar::new(total);
55 pb.set_style(
56 ProgressStyle::with_template(
57 "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | loss: {msg} | ETA: {eta}",
58 )
59 .unwrap_or_else(|_| ProgressStyle::default_bar())
60 .progress_chars("=>-"),
61 );
62 pb
63}
64
65/// Create a progress bar for file downloads.
66///
67/// Shows bytes downloaded, download speed, and estimated time remaining.
68///
69/// # Arguments
70///
71/// * `total_bytes` - Total size of download in bytes
72/// * `verbosity` - Verbosity level to respect
73///
74/// # Returns
75///
76/// A configured progress bar, or hidden bar if progress shouldn't be shown.
77///
78/// # Examples
79///
80/// ```no_run
81/// use oxigaf_cli::progress;
82/// use oxigaf_cli::verbosity::Verbosity;
83///
84/// let pb = progress::download_progress(1024 * 1024 * 100, Verbosity::Normal);
85/// // Update as bytes are received
86/// pb.inc(4096);
87/// pb.finish_with_message("Download complete");
88/// ```
89#[must_use]
90pub fn download_progress(total_bytes: u64, verbosity: Verbosity) -> ProgressBar {
91 if !verbosity.show_progress() {
92 return ProgressBar::hidden();
93 }
94
95 let pb = ProgressBar::new(total_bytes);
96 pb.set_style(
97 ProgressStyle::with_template(
98 "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}) | ETA: {eta}",
99 )
100 .unwrap_or_else(|_| ProgressStyle::default_bar())
101 .progress_chars("=>-"),
102 );
103 pb
104}
105
106/// Create a progress bar for rendering frames.
107///
108/// Shows frame count and rendering progress with ETA.
109///
110/// # Arguments
111///
112/// * `num_frames` - Total number of frames to render
113/// * `verbosity` - Verbosity level to respect
114///
115/// # Returns
116///
117/// A configured progress bar, or hidden bar if progress shouldn't be shown.
118///
119/// # Examples
120///
121/// ```no_run
122/// use oxigaf_cli::progress;
123/// use oxigaf_cli::verbosity::Verbosity;
124///
125/// let pb = progress::render_progress(120, Verbosity::Normal);
126/// for i in 0..120 {
127/// pb.set_message(format!("frame {:03}", i));
128/// pb.inc(1);
129/// }
130/// pb.finish_with_message("Rendering complete");
131/// ```
132#[must_use]
133#[allow(dead_code)]
134pub fn render_progress(num_frames: u64, verbosity: Verbosity) -> ProgressBar {
135 if !verbosity.show_progress() {
136 return ProgressBar::hidden();
137 }
138
139 let pb = ProgressBar::new(num_frames);
140 pb.set_style(
141 ProgressStyle::with_template(
142 "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} frames | {msg} | ETA: {eta}",
143 )
144 .unwrap_or_else(|_| ProgressStyle::default_bar())
145 .progress_chars("=>-"),
146 );
147 pb
148}
149
150/// Create a progress bar for export operations.
151///
152/// Shows item count with custom message support.
153///
154/// # Arguments
155///
156/// * `num_items` - Total number of items to export
157/// * `verbosity` - Verbosity level to respect
158///
159/// # Returns
160///
161/// A configured progress bar, or hidden bar if progress shouldn't be shown.
162///
163/// # Examples
164///
165/// ```no_run
166/// use oxigaf_cli::progress;
167/// use oxigaf_cli::verbosity::Verbosity;
168///
169/// let pb = progress::export_progress(50, Verbosity::Normal);
170/// pb.set_message("extracting");
171/// for i in 0..50 {
172/// pb.inc(1);
173/// }
174/// pb.finish_with_message("Export complete");
175/// ```
176#[must_use]
177pub fn export_progress(num_items: u64, verbosity: Verbosity) -> ProgressBar {
178 if !verbosity.show_progress() {
179 return ProgressBar::hidden();
180 }
181
182 let pb = ProgressBar::new(num_items);
183 pb.set_style(
184 ProgressStyle::with_template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | {msg}")
185 .unwrap_or_else(|_| ProgressStyle::default_bar())
186 .progress_chars("=>-"),
187 );
188 pb
189}
190
191/// Create a spinner for indeterminate operations.
192///
193/// Use when the total work amount is unknown or for quick operations.
194///
195/// # Arguments
196///
197/// * `message` - Message to display next to the spinner
198/// * `verbosity` - Verbosity level to respect
199///
200/// # Returns
201///
202/// A configured spinner, or hidden spinner if progress shouldn't be shown.
203///
204/// # Examples
205///
206/// ```no_run
207/// use oxigaf_cli::progress;
208/// use oxigaf_cli::verbosity::Verbosity;
209///
210/// let pb = progress::spinner("Downloading...", Verbosity::Normal);
211/// // Do work
212/// pb.finish_with_message("Done!");
213/// ```
214#[must_use]
215pub fn spinner(message: &str, verbosity: Verbosity) -> ProgressBar {
216 if !verbosity.show_progress() {
217 return ProgressBar::hidden();
218 }
219
220 let pb = ProgressBar::new_spinner();
221 pb.set_style(
222 ProgressStyle::with_template("{spinner:.green} {msg}")
223 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
224 );
225 pb.set_message(message.to_string());
226 pb
227}
228
229/// Create a multi-progress container for parallel operations.
230///
231/// Allows multiple progress bars to be displayed simultaneously.
232///
233/// # Arguments
234///
235/// * `verbosity` - Verbosity level to respect
236///
237/// # Returns
238///
239/// A multi-progress container if progress should be shown, None otherwise.
240///
241/// # Examples
242///
243/// ```no_run
244/// use oxigaf_cli::progress;
245/// use oxigaf_cli::verbosity::Verbosity;
246///
247/// if let Some(multi) = progress::multi_progress(Verbosity::Normal) {
248/// let pb1 = multi.add(progress::render_progress(100, Verbosity::Normal));
249/// let pb2 = multi.add(progress::render_progress(100, Verbosity::Normal));
250/// // Use pb1 and pb2 for parallel work
251/// }
252/// ```
253#[must_use]
254pub fn multi_progress(verbosity: Verbosity) -> Option<MultiProgress> {
255 if verbosity.show_progress() {
256 Some(MultiProgress::new())
257 } else {
258 None
259 }
260}
261
262/// Create a generic progress bar with custom template.
263///
264/// For specialized use cases not covered by the predefined helpers.
265///
266/// # Arguments
267///
268/// * `total` - Total number of items/iterations
269/// * `template` - Custom template string (see indicatif documentation)
270/// * `verbosity` - Verbosity level to respect
271///
272/// # Returns
273///
274/// A configured progress bar, or hidden bar if progress shouldn't be shown.
275///
276/// # Examples
277///
278/// ```no_run
279/// use oxigaf_cli::progress;
280/// use oxigaf_cli::verbosity::Verbosity;
281///
282/// let pb = progress::custom_progress(
283/// 1000,
284/// "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} iterations | {msg}",
285/// Verbosity::Normal,
286/// );
287/// ```
288#[must_use]
289pub fn custom_progress(total: u64, template: &str, verbosity: Verbosity) -> ProgressBar {
290 if !verbosity.show_progress() {
291 return ProgressBar::hidden();
292 }
293
294 let pb = ProgressBar::new(total);
295 pb.set_style(
296 ProgressStyle::with_template(template)
297 .unwrap_or_else(|_| ProgressStyle::default_bar())
298 .progress_chars("=>-"),
299 );
300 pb
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn training_progress_hidden_in_quiet_mode() {
313 let pb = training_progress(100, Verbosity::Quiet);
314 assert!(pb.is_hidden());
315 }
316
317 #[test]
318 fn training_progress_hidden_in_debug_mode() {
319 let pb = training_progress(100, Verbosity::Debug);
320 assert!(pb.is_hidden());
321 }
322
323 #[test]
324 fn training_progress_hidden_in_trace_mode() {
325 let pb = training_progress(100, Verbosity::Trace);
326 assert!(pb.is_hidden());
327 }
328
329 #[test]
330 fn training_progress_visible_in_normal_mode() {
331 let pb = training_progress(100, Verbosity::Normal);
332 // Check that progress bar has the expected length
333 assert_eq!(pb.length(), Some(100));
334 }
335
336 #[test]
337 fn training_progress_visible_in_verbose_mode() {
338 let pb = training_progress(100, Verbosity::Verbose);
339 // Check that progress bar has the expected length
340 assert_eq!(pb.length(), Some(100));
341 }
342
343 #[test]
344 fn download_progress_hidden_in_quiet_mode() {
345 let pb = download_progress(1000, Verbosity::Quiet);
346 assert!(pb.is_hidden());
347 }
348
349 #[test]
350 fn download_progress_visible_in_normal_mode() {
351 let pb = download_progress(1000, Verbosity::Normal);
352 // Check that progress bar has the expected length
353 assert_eq!(pb.length(), Some(1000));
354 }
355
356 #[test]
357 fn render_progress_respects_verbosity() {
358 assert!(render_progress(100, Verbosity::Quiet).is_hidden());
359 assert_eq!(render_progress(100, Verbosity::Normal).length(), Some(100));
360 assert_eq!(render_progress(100, Verbosity::Verbose).length(), Some(100));
361 assert!(render_progress(100, Verbosity::Debug).is_hidden());
362 assert!(render_progress(100, Verbosity::Trace).is_hidden());
363 }
364
365 #[test]
366 fn export_progress_respects_verbosity() {
367 assert!(export_progress(50, Verbosity::Quiet).is_hidden());
368 assert_eq!(export_progress(50, Verbosity::Normal).length(), Some(50));
369 assert_eq!(export_progress(50, Verbosity::Verbose).length(), Some(50));
370 assert!(export_progress(50, Verbosity::Debug).is_hidden());
371 }
372
373 #[test]
374 fn spinner_respects_verbosity() {
375 let pb = spinner("test", Verbosity::Quiet);
376 assert!(pb.is_hidden());
377
378 let pb = spinner("test", Verbosity::Normal);
379 // Spinners have no length, just check it's not hidden by checking it has a style
380 assert!(!pb.is_finished());
381
382 let pb = spinner("test", Verbosity::Verbose);
383 assert!(!pb.is_finished());
384
385 let pb = spinner("test", Verbosity::Debug);
386 assert!(pb.is_hidden());
387 }
388
389 #[test]
390 fn multi_progress_respects_verbosity() {
391 assert!(multi_progress(Verbosity::Quiet).is_none());
392 assert!(multi_progress(Verbosity::Normal).is_some());
393 assert!(multi_progress(Verbosity::Verbose).is_some());
394 assert!(multi_progress(Verbosity::Debug).is_none());
395 }
396
397 #[test]
398 fn custom_progress_respects_verbosity() {
399 let template = "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len}";
400
401 assert!(custom_progress(100, template, Verbosity::Quiet).is_hidden());
402 assert_eq!(
403 custom_progress(100, template, Verbosity::Normal).length(),
404 Some(100)
405 );
406 assert_eq!(
407 custom_progress(100, template, Verbosity::Verbose).length(),
408 Some(100)
409 );
410 assert!(custom_progress(100, template, Verbosity::Debug).is_hidden());
411 }
412
413 #[test]
414 fn progress_bars_have_correct_length() {
415 let pb = training_progress(1000, Verbosity::Normal);
416 assert_eq!(pb.length(), Some(1000));
417
418 let pb = download_progress(2048, Verbosity::Normal);
419 assert_eq!(pb.length(), Some(2048));
420
421 let pb = render_progress(120, Verbosity::Normal);
422 assert_eq!(pb.length(), Some(120));
423
424 let pb = export_progress(50, Verbosity::Normal);
425 assert_eq!(pb.length(), Some(50));
426 }
427}