Skip to content

Commit 6bfe6c4

Browse files
committed
fix(cli): fit download progress to narrow terminals
1 parent 7c51cc1 commit 6bfe6c4

3 files changed

Lines changed: 179 additions & 54 deletions

File tree

‎crates/vp_js_runtime/src/download.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,7 @@ pub async fn download_file(
5050
let is_ci = vp_shared::EnvConfig::get().is_ci;
5151
let progress = if vp_shared::is_stderr_terminal() && !is_ci {
5252
let pb = ProgressBar::new_spinner();
53-
pb.set_style(vp_shared::download_progress::spinner_style());
54-
pb.set_message(message.to_string());
53+
pb.set_style(vp_shared::download_progress::download_style(message));
5554
pb.enable_steady_tick(Duration::from_millis(100));
5655
Some(pb)
5756
} else {
@@ -126,7 +125,6 @@ pub async fn download_file(
126125
pb.set_position(if is_resumed { resume_from } else { 0 });
127126
if let Some(size) = total_size {
128127
pb.set_length(size);
129-
pb.set_style(vp_shared::download_progress::bar_style());
130128
}
131129
}
132130

‎crates/vp_pm_cli/src/request.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,7 @@ impl HttpClient {
175175
&& !is_ci
176176
{
177177
let pb = ProgressBar::new_spinner();
178-
pb.set_style(vp_shared::download_progress::spinner_style());
179-
pb.set_message(message.to_string());
178+
pb.set_style(vp_shared::download_progress::download_style(message));
180179
pb.enable_steady_tick(Duration::from_millis(100));
181180
Some(pb)
182181
} else {
@@ -198,7 +197,6 @@ impl HttpClient {
198197
pb.set_position(0);
199198
if let Some(size) = response.content_length() {
200199
pb.set_length(size);
201-
pb.set_style(vp_shared::download_progress::bar_style());
202200
}
203201
}
204202
Self::write_response_to_file(response, target_path, progress.as_ref()).await

‎crates/vp_shared/src/download_progress.rs‎

Lines changed: 177 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,200 @@
11
//! Single-row download progress shared by runtime and package-manager downloads.
22
3-
use indicatif::ProgressStyle;
3+
use std::fmt;
44

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+
})
1030
}
1131

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}") }
2475
}
2576

2677
#[cfg(test)]
2778
mod tests {
28-
use std::time::Duration;
79+
use std::{
80+
sync::{
81+
Arc,
82+
atomic::{AtomicU16, Ordering},
83+
},
84+
time::Duration,
85+
};
2986

3087
use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike};
3188

3289
use super::*;
3390

3491
#[test]
3592
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+
}
61131
}
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());
62138
}
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+
}
63171

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}");
66197
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");
69198
}
70199
}
71200
}

0 commit comments

Comments
 (0)