|
1 | 1 | //! Single-row download progress shared by runtime and package-manager downloads. |
2 | 2 |
|
3 | | -use indicatif::ProgressStyle; |
| 3 | +use std::fmt; |
4 | 4 |
|
5 | | -/// Progress before the server supplies a content length. |
6 | | -pub fn spinner_style() -> ProgressStyle { |
7 | | - ProgressStyle::default_spinner() |
8 | | - .template("{spinner:.green} {msg} {bytes} ({bytes_per_sec})") |
9 | | - .expect("valid download spinner template") |
| 5 | +use console::{Term, measure_text_width, style, truncate_str}; |
| 6 | +use indicatif::{HumanBytes, HumanDuration, ProgressState, ProgressStyle}; |
| 7 | +use vt_str::{Str, format}; |
| 8 | + |
| 9 | +/// Fit the download message and statistics to stderr on every redraw. |
| 10 | +/// The same style handles responses with and without a content length. |
| 11 | +pub fn download_style(message: &str) -> ProgressStyle { |
| 12 | + let term = Term::stderr(); |
| 13 | + style_with_width(message, move || term.size().1) |
| 14 | +} |
| 15 | + |
| 16 | +fn style_with_width( |
| 17 | + message: &str, |
| 18 | + width: impl Fn() -> u16 + Clone + Send + Sync + 'static, |
| 19 | +) -> ProgressStyle { |
| 20 | + let message = Str::from(message); |
| 21 | + ProgressStyle::with_template("{spinner:.green}{download}") |
| 22 | + .expect("valid download progress template") |
| 23 | + .with_key("download", move |state: &ProgressState, output: &mut dyn fmt::Write| { |
| 24 | + // Reserve one column for the spinner. Read the width on each draw |
| 25 | + // so a terminal resize also changes the message and statistics. |
| 26 | + let available = usize::from(width()).saturating_sub(1); |
| 27 | + let row = download_row(state, &message, available); |
| 28 | + write!(output, "{}", truncate_str(&row, available, "")).unwrap(); |
| 29 | + }) |
10 | 30 | } |
11 | 31 |
|
12 | | -/// Progress with a known content length. |
13 | | -pub fn bar_style() -> ProgressStyle { |
14 | | - // Keep the message on the same row and let the bar use the remaining width. |
15 | | - // Multiline redraws can move upwards into earlier shell output when the |
16 | | - // terminal's line wrapping differs from indicatif's line count. |
17 | | - ProgressStyle::default_bar() |
18 | | - .template( |
19 | | - "{spinner:.green} {msg} [{wide_bar:.blue/white}] \ |
20 | | - {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", |
21 | | - ) |
22 | | - .expect("valid download progress bar template") |
23 | | - .progress_chars("#>-") |
| 32 | +fn download_row(state: &ProgressState, message: &str, available: usize) -> Str { |
| 33 | + let bytes = HumanBytes(state.pos()); |
| 34 | + let counts = match state.len() { |
| 35 | + Some(total) => format!("{bytes}/{}", HumanBytes(total)), |
| 36 | + None => format!("{bytes}"), |
| 37 | + }; |
| 38 | + let speed = HumanBytes(state.per_sec() as u64); |
| 39 | + let details = match state.len() { |
| 40 | + Some(_) => format!("{counts} ({speed}/s, {:#})", HumanDuration(state.eta())), |
| 41 | + None => format!("{counts} ({speed}/s)"), |
| 42 | + }; |
| 43 | + let fixed_width = measure_text_width(message) + measure_text_width(&details) + 2; |
| 44 | + if state.len().is_some() { |
| 45 | + // Keep a useful bar only when the full message and statistics fit. |
| 46 | + // The leading space, brackets, and trailing space use three columns |
| 47 | + // in addition to the two spaces included in `fixed_width`. |
| 48 | + let bar_width = available.saturating_sub(fixed_width + 3); |
| 49 | + if bar_width >= 4 { |
| 50 | + let filled = (state.fraction() * bar_width as f32) as usize; |
| 51 | + let remaining = bar_width - filled; |
| 52 | + let bar = format!("{}{}", "#".repeat(filled), if remaining > 0 { ">" } else { "" }); |
| 53 | + return format!( |
| 54 | + " {message} [{}{}] {details}", |
| 55 | + style(bar).blue(), |
| 56 | + style("-".repeat(remaining.saturating_sub(1))).white(), |
| 57 | + ); |
| 58 | + } |
| 59 | + } else if fixed_width <= available { |
| 60 | + return format!(" {message} {details}"); |
| 61 | + } |
| 62 | + |
| 63 | + // On narrow terminals, omit the bar, speed, and ETA before shortening the |
| 64 | + // message. For very small panes, prefer a percentage to two byte counts. |
| 65 | + let counts = if state.len().is_some() |
| 66 | + && measure_text_width(&counts) + 2 + measure_text_width(message).min(8) > available |
| 67 | + { |
| 68 | + format!("{:.0}%", state.fraction() * 100.0) |
| 69 | + } else { |
| 70 | + counts |
| 71 | + }; |
| 72 | + let message_width = available.saturating_sub(measure_text_width(&counts) + 2); |
| 73 | + let message = truncate_str(message, message_width, ""); |
| 74 | + if message.is_empty() { format!(" {counts}") } else { format!(" {message} {counts}") } |
24 | 75 | } |
25 | 76 |
|
26 | 77 | #[cfg(test)] |
27 | 78 | mod tests { |
28 | | - use std::time::Duration; |
| 79 | + use std::{ |
| 80 | + sync::{ |
| 81 | + Arc, |
| 82 | + atomic::{AtomicU16, Ordering}, |
| 83 | + }, |
| 84 | + time::Duration, |
| 85 | + }; |
29 | 86 |
|
30 | 87 | use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike}; |
31 | 88 |
|
32 | 89 | use super::*; |
33 | 90 |
|
34 | 91 | #[test] |
35 | 92 | fn redraws_stay_on_one_row_and_preserve_earlier_output() { |
36 | | - for width in [80, 138] { |
37 | | - for message in ["Downloading node v26.8.0...", "Downloading pnpm v11.24.0..."] { |
38 | | - let term = InMemoryTerm::new(10, width); |
39 | | - term.write_line("Earlier shell output").unwrap(); |
40 | | - let progress = ProgressBar::with_draw_target( |
41 | | - None, |
42 | | - ProgressDrawTarget::term_like(Box::new(term.clone())), |
43 | | - ); |
44 | | - progress.set_style(spinner_style()); |
45 | | - progress.set_message(message); |
46 | | - |
47 | | - for known_length in [false, true] { |
48 | | - if known_length { |
49 | | - progress.set_length(55 * 1024 * 1024); |
50 | | - progress.set_style(bar_style()); |
51 | | - } |
52 | | - for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024] { |
53 | | - progress.set_elapsed(Duration::from_secs(2)); |
54 | | - progress.set_position(position); |
55 | | - progress.force_draw(); |
56 | | - let screen = term.contents(); |
57 | | - let lines: Vec<_> = screen.lines().collect(); |
58 | | - assert_eq!(lines.len(), 2, "width {width}: {screen}"); |
59 | | - assert_eq!(lines[0], "Earlier shell output"); |
60 | | - assert!(lines[1].contains(message), "{screen}"); |
| 93 | + for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { |
| 94 | + for message in [ |
| 95 | + "Downloading node v26.8.0...", |
| 96 | + "Downloading pnpm v11.24.0...", |
| 97 | + "Downloading a package with a very long name and wide characters 界界界界界界界界界界界界...", |
| 98 | + ] { |
| 99 | + for total in [55 * 1024 * 1024, u64::MAX] { |
| 100 | + let earlier = if width >= 20 { "Earlier shell output" } else { "A" }; |
| 101 | + let term = InMemoryTerm::new(10, width); |
| 102 | + term.write_line(earlier).unwrap(); |
| 103 | + let progress = ProgressBar::with_draw_target( |
| 104 | + None, |
| 105 | + ProgressDrawTarget::term_like(Box::new(term.clone())), |
| 106 | + ); |
| 107 | + let draw_term = term.clone(); |
| 108 | + progress.set_style(style_with_width(message, move || draw_term.width())); |
| 109 | + |
| 110 | + for known_length in [false, true] { |
| 111 | + if known_length { |
| 112 | + progress.set_length(total); |
| 113 | + } |
| 114 | + for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024, total] { |
| 115 | + progress.set_elapsed(Duration::from_secs(2)); |
| 116 | + progress.set_position(position); |
| 117 | + for reset_speed in [false, true] { |
| 118 | + if reset_speed { |
| 119 | + progress.reset_eta(); |
| 120 | + } |
| 121 | + progress.force_draw(); |
| 122 | + let screen = term.contents(); |
| 123 | + let lines: Vec<_> = screen.lines().collect(); |
| 124 | + assert_eq!(lines.len(), 2, "width {width}: {screen}"); |
| 125 | + assert_eq!(lines[0], earlier); |
| 126 | + assert!(measure_text_width(lines[1]) <= usize::from(width)); |
| 127 | + let moves = term.moves_since_last_check(); |
| 128 | + assert!(!moves.contains("Up("), "width {width}: {moves}"); |
| 129 | + } |
| 130 | + } |
61 | 131 | } |
| 132 | + |
| 133 | + progress.finish_and_clear(); |
| 134 | + assert_eq!(term.contents(), earlier); |
| 135 | + assert!(!term.moves_since_last_check().contains("Up(")); |
| 136 | + term.write_line("B").unwrap(); |
| 137 | + assert_eq!(term.contents(), vt_str::format!("{earlier}\nB").as_str()); |
62 | 138 | } |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + #[test] |
| 144 | + fn compact_layout_preserves_download_counts() { |
| 145 | + for (width, has_bar) in [(40, false), (60, false), (80, true), (138, true)] { |
| 146 | + let term = InMemoryTerm::new(5, width); |
| 147 | + let progress = ProgressBar::with_draw_target( |
| 148 | + None, |
| 149 | + ProgressDrawTarget::term_like(Box::new(term.clone())), |
| 150 | + ); |
| 151 | + let draw_term = term.clone(); |
| 152 | + progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { |
| 153 | + draw_term.width() |
| 154 | + })); |
| 155 | + progress.set_position(25 * 1024 * 1024); |
| 156 | + progress.reset_eta(); |
| 157 | + progress.force_draw(); |
| 158 | + assert!(term.contents().contains("25.00 MiB")); |
| 159 | + |
| 160 | + progress.set_length(55 * 1024 * 1024); |
| 161 | + progress.force_draw(); |
| 162 | + let screen = term.contents(); |
| 163 | + assert!(screen.contains("25.00 MiB/55.00 MiB"), "{screen}"); |
| 164 | + assert_eq!(screen.contains('['), has_bar, "{screen}"); |
| 165 | + assert_eq!(screen.contains("0 B/s"), has_bar, "{screen}"); |
| 166 | + if width >= 60 { |
| 167 | + assert!(screen.contains("Downloading pnpm v11.24.0..."), "{screen}"); |
| 168 | + } |
| 169 | + } |
| 170 | + } |
63 | 171 |
|
64 | | - progress.finish_and_clear(); |
65 | | - assert_eq!(term.contents(), "Earlier shell output"); |
| 172 | + #[test] |
| 173 | + fn layout_reads_the_current_width_on_each_draw() { |
| 174 | + let width = Arc::new(AtomicU16::new(138)); |
| 175 | + let term = InMemoryTerm::new(5, 138); |
| 176 | + let progress = ProgressBar::with_draw_target( |
| 177 | + None, |
| 178 | + ProgressDrawTarget::term_like(Box::new(term.clone())), |
| 179 | + ); |
| 180 | + let draw_width = Arc::clone(&width); |
| 181 | + progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { |
| 182 | + draw_width.load(Ordering::Relaxed) |
| 183 | + })); |
| 184 | + progress.set_position(25 * 1024 * 1024); |
| 185 | + progress.reset_eta(); |
| 186 | + |
| 187 | + for known_length in [false, true] { |
| 188 | + if known_length { |
| 189 | + progress.set_length(55 * 1024 * 1024); |
| 190 | + } |
| 191 | + for columns in [138, 60, 40, 1, 80, 138] { |
| 192 | + width.store(columns, Ordering::Relaxed); |
| 193 | + progress.force_draw(); |
| 194 | + let screen = term.contents(); |
| 195 | + assert_eq!(screen.lines().count(), 1, "{screen}"); |
| 196 | + assert!(measure_text_width(&screen) <= usize::from(columns), "{screen}"); |
66 | 197 | assert!(!term.moves_since_last_check().contains("Up(")); |
67 | | - term.write_line("Next shell prompt").unwrap(); |
68 | | - assert_eq!(term.contents(), "Earlier shell output\nNext shell prompt"); |
69 | 198 | } |
70 | 199 | } |
71 | 200 | } |
|
0 commit comments