atomic_progress/frontends/
terminal.rs1use std::{
9 io::{self, Write},
10 time::Duration,
11};
12
13use prettier_bytes::ByteFormatter;
14
15use crate::{ProgressSnapshot, ProgressStackSnapshot, ProgressType};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Theme {
23 pub bar_filled: char,
25 pub bar_empty: char,
27 pub spinner_frames: &'static [char],
29}
30
31impl Default for Theme {
32 fn default() -> Self {
34 Self::modern()
35 }
36}
37
38impl Theme {
39 #[must_use]
44 pub const fn ascii() -> Self {
45 Self {
46 bar_filled: '#',
47 bar_empty: '-',
48 spinner_frames: &['|', '/', '-', '\\'],
49 }
50 }
51
52 #[must_use]
57 pub const fn modern() -> Self {
58 Self {
59 bar_filled: '█',
60 bar_empty: '░',
61 spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
62 }
63 }
64}
65
66struct FormattedMetrics {
71 elapsed: String,
73 pos: String,
75 total: String,
77 rate: String,
79 eta: String,
81}
82
83pub struct TerminalFrontend<W> {
88 writer: W,
90 last_lines: usize,
92 width: usize,
94 theme: Theme,
96 spinner_tick: usize,
98 byte_formatter: Option<ByteFormatter>,
100}
101
102impl<W: Write> TerminalFrontend<W> {
103 pub const fn new(writer: W) -> Self {
113 Self {
114 writer,
115 last_lines: 0,
116 width: 40,
117 theme: Theme {
118 bar_filled: '█',
119 bar_empty: '░',
120 spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
121 },
122 spinner_tick: 0,
123 byte_formatter: None,
124 }
125 }
126
127 #[must_use]
129 pub const fn with_theme(mut self, theme: Theme) -> Self {
130 self.theme = theme;
131 self
132 }
133
134 #[must_use]
136 pub const fn with_width(mut self, width: usize) -> Self {
137 self.width = width;
138 self
139 }
140
141 #[must_use]
144 pub const fn with_byte_formatting(mut self, formatter: ByteFormatter) -> Self {
145 self.byte_formatter = Some(formatter);
146 self
147 }
148
149 fn move_cursor_up(&mut self, n: usize) -> io::Result<()> {
154 if n > 0 {
155 write!(self.writer, "\x1b[{n}A")?;
157 }
158 Ok(())
159 }
160
161 fn format_line(&self, snapshot: &ProgressSnapshot) -> String {
166 let metrics = self.format_metrics(snapshot);
167
168 match snapshot.kind {
169 ProgressType::Bar => self.format_bar(snapshot, &metrics),
170 ProgressType::Spinner => self.format_spinner(snapshot, &metrics),
171 }
172 }
173
174 fn format_metrics(&self, snapshot: &ProgressSnapshot) -> FormattedMetrics {
178 let elapsed = snapshot
179 .elapsed
180 .map_or_else(|| "--:--".to_string(), format_duration);
181
182 let (pos, total) = self.byte_formatter.as_ref().map_or_else(
184 || (snapshot.position.to_string(), snapshot.total.to_string()),
185 |bf| {
186 (
187 bf.format(snapshot.position).to_string(),
188 bf.format(snapshot.total).to_string(),
189 )
190 },
191 );
192
193 let rate_val = snapshot.throughput();
194 let rate = if rate_val > 0.0 {
195 self.byte_formatter.as_ref().map_or_else(
196 || format!("{rate_val:.1}/s"),
197 |bf| format!("{}/s", bf.format(rate_val as u64)),
198 )
199 } else if self.byte_formatter.is_some() {
200 "--.- B/s".to_string()
202 } else {
203 "--.-/s".to_string()
205 };
206
207 let eta = snapshot.eta().map_or_else(String::new, |eta_val| {
208 format!(" | ETA {}", format_duration(eta_val))
209 });
210
211 FormattedMetrics {
212 elapsed,
213 pos,
214 total,
215 rate,
216 eta,
217 }
218 }
219
220 fn format_bar(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
222 use std::fmt::Write as _;
223
224 #[allow(clippy::cast_precision_loss)]
225 let percent = if snapshot.total == 0 {
226 0.0
227 } else {
228 (snapshot.position as f64 / snapshot.total as f64) * 100.0
229 };
230
231 let percent = percent.clamp(0.0, 100.0);
233
234 #[allow(clippy::cast_precision_loss)]
236 let filled_float = (percent / 100.0) * (self.width as f64);
237
238 let filled = if filled_float.is_nan() || filled_float.is_infinite() || filled_float < 0.0 {
240 0
241 } else {
242 filled_float as usize
243 }
244 .min(self.width);
245
246 let empty = self.width.saturating_sub(filled);
247
248 let filled_str = self.theme.bar_filled.to_string().repeat(filled);
249 let empty_str = self.theme.bar_empty.to_string().repeat(empty);
250
251 let status = if snapshot.finished {
253 if snapshot.error.is_some() {
254 "✖"
255 } else {
256 "✔"
257 }
258 } else {
259 ""
260 };
261
262 let mut info = String::new();
264 if !snapshot.name.is_empty() {
265 info.push_str(&snapshot.name);
266 }
267 if !snapshot.item.is_empty() {
268 if !info.is_empty() {
269 info.push(' ');
270 }
271 let _ = write!(info, "[{}]", snapshot.item);
272 }
273 if let Some(err) = &snapshot.error {
274 if !info.is_empty() {
275 info.push(' ');
276 }
277 let _ = write!(info, "ERROR: {err}");
278 }
279
280 format!(
281 "{status}{}[{filled_str}{empty_str}] {percent:>5.1}% ({}/{}) | {}{} | {} | {info}",
282 if status.is_empty() { "" } else { " " },
283 metrics.pos,
284 metrics.total,
285 metrics.elapsed,
286 metrics.eta,
287 metrics.rate,
288 )
289 }
290
291 fn format_spinner(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
293 use std::fmt::Write as _;
294
295 let frame = if snapshot.finished {
297 if snapshot.error.is_some() {
298 '✖'
299 } else {
300 '✔'
301 }
302 } else if self.theme.spinner_frames.is_empty() {
303 ' '
304 } else {
305 self.theme.spinner_frames[self.spinner_tick % self.theme.spinner_frames.len()]
306 };
307
308 let name_prefix = if snapshot.name.is_empty() {
309 String::new()
310 } else {
311 format!("{} ", snapshot.name)
312 };
313
314 let mut info = String::new();
316 if !snapshot.item.is_empty() {
317 let _ = write!(info, " [{}]", snapshot.item);
318 }
319 if let Some(err) = &snapshot.error {
320 let _ = write!(info, " ERROR: {err}");
321 }
322
323 let items_label = if self.byte_formatter.is_some() {
324 ""
325 } else {
326 " items"
327 };
328
329 format!(
330 "{frame} {name_prefix}{}{items_label} | {} | {}{info}",
331 metrics.pos, metrics.elapsed, metrics.rate
332 )
333 }
334}
335
336impl<W: Write> super::Frontend for TerminalFrontend<W> {
337 fn render(&mut self, snapshot: &ProgressSnapshot) -> io::Result<()> {
338 self.move_cursor_up(self.last_lines)?;
339
340 let line = self.format_line(snapshot);
341 writeln!(self.writer, "\x1b[2K\r{line}")?;
343
344 self.spinner_tick = self.spinner_tick.wrapping_add(1);
346 self.last_lines = 1;
347 self.writer.flush()?;
348 Ok(())
349 }
350
351 fn render_stack(&mut self, stack: &ProgressStackSnapshot) -> io::Result<()> {
352 self.move_cursor_up(self.last_lines)?;
353
354 for snapshot in &stack.0 {
355 let line = self.format_line(snapshot);
356 writeln!(self.writer, "\x1b[2K\r{line}")?;
357 }
358
359 if self.last_lines > stack.0.len() {
361 let diff = self.last_lines - stack.0.len();
362 for _ in 0..diff {
363 writeln!(self.writer, "\x1b[2K\r")?;
364 }
365 self.move_cursor_up(diff)?;
366 }
367
368 self.spinner_tick = self.spinner_tick.wrapping_add(1);
369 self.last_lines = stack.0.len();
370 self.writer.flush()?;
371 Ok(())
372 }
373
374 fn clear(&mut self) -> io::Result<()> {
375 self.move_cursor_up(self.last_lines)?;
376 for _ in 0..self.last_lines {
377 writeln!(self.writer, "\x1b[2K\r")?;
378 }
379 self.move_cursor_up(self.last_lines)?;
380
381 self.last_lines = 0;
382 self.writer.flush()?;
383 Ok(())
384 }
385
386 fn finish(&mut self) -> io::Result<()> {
387 self.last_lines = 0;
388 self.writer.flush()?;
389 Ok(())
390 }
391}
392
393fn format_duration(d: Duration) -> String {
395 let secs = d.as_secs();
396 if secs >= 3600 {
397 format!(
398 "{:02}:{:02}:{:02}",
399 secs / 3600,
400 (secs % 3600) / 60,
401 secs % 60
402 )
403 } else {
404 format!("{:02}:{:02}", secs / 60, secs % 60)
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use std::thread;
411
412 use compact_str::CompactString;
413
414 use super::*;
415 use crate::{ProgressBuilder, ProgressStack, ProgressType, frontends::Frontend};
416
417 #[test]
418 fn test_format_duration() {
419 assert_eq!(format_duration(Duration::from_secs(45)), "00:45");
420 assert_eq!(format_duration(Duration::from_secs(125)), "02:05");
421 assert_eq!(format_duration(Duration::from_secs(3665)), "01:01:05");
422 }
423
424 #[test]
425 fn test_terminal_frontend_rendering() {
426 let mut buf = Vec::new();
427 {
428 let mut frontend = TerminalFrontend::new(&mut buf).with_theme(Theme::ascii());
429
430 let snap = ProgressSnapshot {
431 name: CompactString::new("Test"),
432 kind: ProgressType::Bar,
433 position: 50,
434 total: 100,
435 ..Default::default()
436 };
437
438 frontend.render(&snap).unwrap();
439 }
440
441 let out = String::from_utf8(buf).unwrap();
442 assert!(out.contains("[####################--------------------]"));
443 assert!(out.contains("50.0%"));
444 assert!(out.contains("\x1b[2K\r")); }
446
447 #[test]
451 #[ignore = "Visual test that writes to stderr and sleeps"]
452 fn test_real_terminal_output() {
453 let stack = ProgressStack::new();
454
455 let bar = ProgressBuilder::new_bar("Downloading", 100u64)
457 .with_start_time_now()
458 .build();
459 stack.push(bar.clone());
460 let spinner = ProgressBuilder::new_spinner("Processing")
461 .with_start_time_now()
462 .build();
463 stack.push(spinner.clone());
464
465 let worker = thread::spawn(move || {
467 for i in 0..=100 {
468 bar.set_pos(i);
469 bar.set_item(format!("chunk_{i}.bin"));
470
471 spinner.bump();
472 spinner.set_item(format!("tasks: {i}"));
473
474 thread::sleep(Duration::from_millis(30));
475 }
476
477 bar.finish_with_item("Complete!");
478 spinner.finish_with_item("Done!");
479 });
480
481 let mut frontend = TerminalFrontend::new(std::io::stderr());
483
484 while !stack.is_all_finished() {
485 let snapshot = stack.snapshot();
486 frontend.render_stack(&snapshot).unwrap();
487
488 thread::sleep(Duration::from_millis(33));
490 }
491
492 let final_snapshot = stack.snapshot();
494 frontend.render_stack(&final_snapshot).unwrap();
495 frontend.finish().unwrap();
496
497 worker.join().unwrap();
498 }
499}