The pull downloads reported through the plain count port only because
byte_progress landed on main after the branch was cut. PullView now
implements it with a byte-counter template in the transfer-style
family (same shape, {bytes}/{total_bytes} counters, human-formatted by
indicatif), downloads emit through it, and the trait doc lists the
event among the ones the pull flow emits — un-duplicating the merged
'message, progress and progress' sentence.
209 lines
7.0 KiB
Rust
209 lines
7.0 KiB
Rust
//! Terminal UI helpers: progress bars, interactive prompts and live build
|
|
//! views.
|
|
|
|
/// Live build view for `pkh deb` (status bar + rolling log pane)
|
|
pub mod deb;
|
|
/// Interactive raw-mode prompts: free-text input, option selection and
|
|
/// yes/no confirmation
|
|
pub mod prompt;
|
|
|
|
use crossterm::terminal;
|
|
#[cfg(test)]
|
|
use indicatif::ProgressDrawTarget;
|
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|
use std::time::Duration;
|
|
|
|
/// Style of an unsized operation: spinner and prefix on one line
|
|
pub(crate) fn spinner_style() -> ProgressStyle {
|
|
ProgressStyle::default_bar()
|
|
.template("> {spinner:.blue} {prefix}")
|
|
.unwrap()
|
|
}
|
|
|
|
/// Progress draw target for a [`MultiProgress`]: hidden in test runs
|
|
///
|
|
/// Steady-tick spinners redraw from a background thread straight to the real
|
|
/// stderr, bypassing both the test harness capture and the per-test log
|
|
/// files; in tests there is no terminal to animate anyway.
|
|
#[cfg(test)]
|
|
pub(crate) fn progress_draw_target() -> ProgressDrawTarget {
|
|
ProgressDrawTarget::hidden()
|
|
}
|
|
|
|
/// Style of a sized transfer: prefix on the first line, the bar on its own
|
|
/// indented line below so long prefixes cannot push it out of the terminal
|
|
pub(crate) fn transfer_style() -> ProgressStyle {
|
|
ProgressStyle::default_bar()
|
|
.template(
|
|
"> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
|
|
)
|
|
.unwrap()
|
|
.progress_chars("=> ")
|
|
}
|
|
|
|
/// [`transfer_style`] with human-formatted byte counters, for the events
|
|
/// reported through [`crate::report::BuildView::byte_progress`]
|
|
pub(crate) fn byte_style() -> ProgressStyle {
|
|
ProgressStyle::default_bar()
|
|
.template(
|
|
"> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})",
|
|
)
|
|
.unwrap()
|
|
.progress_chars("=> ")
|
|
}
|
|
|
|
/// A standalone spinner for one-line waits outside a build view (the
|
|
/// prune size pass): braille frames over the message behind the `> `
|
|
/// prompt prefix, drawn only on terminals. Callers finish it with
|
|
/// [`ProgressBar::finish_and_clear`].
|
|
pub fn wait_spinner(msg: &str) -> ProgressBar {
|
|
let pb = ProgressBar::new_spinner();
|
|
pb.set_style(
|
|
ProgressStyle::with_template("> {spinner:.blue} {msg}").expect("spinner template"),
|
|
);
|
|
pb.set_message(msg.to_string());
|
|
pb.enable_steady_tick(Duration::from_millis(120));
|
|
pb
|
|
}
|
|
|
|
/// Width of the terminal in columns, or `None` when it cannot be determined
|
|
pub(crate) fn terminal_width() -> Option<usize> {
|
|
terminal::size().ok().map(|(cols, _)| cols as usize)
|
|
}
|
|
|
|
/// Removal progress bar of `pkh prune`, in the deb live view's layout:
|
|
/// the running count behind the spinner on the first line, the byte bar
|
|
/// with its counters on the second when the plan carries sizes (`--force`
|
|
/// skips the sizing and gets the count line only). The removed path is
|
|
/// deliberately not rendered — it fights the bar on a narrow terminal,
|
|
/// and the failed-removal errors name the paths that matter. Drive it
|
|
/// with [`removal_progress`]; finish it with
|
|
/// [`ProgressBar::finish_and_clear`].
|
|
pub fn removal_bar(multi: &MultiProgress, total_bytes: u64) -> ProgressBar {
|
|
let pb = multi.add(ProgressBar::new(total_bytes));
|
|
let template = if total_bytes > 0 {
|
|
"> {spinner:.blue} {prefix}\n [{bar:24.cyan/blue}] {bytes}/{total_bytes}"
|
|
} else {
|
|
"> {spinner:.blue} {prefix}"
|
|
};
|
|
pb.set_style(
|
|
ProgressStyle::with_template(template)
|
|
.expect("removal template")
|
|
.progress_chars("=> "),
|
|
);
|
|
pb.enable_steady_tick(Duration::from_millis(120));
|
|
pb
|
|
}
|
|
|
|
/// Advance the removal bar to the `idx`-th of `count` artifact;
|
|
/// `done` bytes already freed
|
|
pub fn removal_progress(bar: &ProgressBar, idx: usize, count: usize, done: u64) {
|
|
bar.set_prefix(format!("Removing {}/{}", idx + 1, count));
|
|
if let Some(total) = bar.length()
|
|
&& total > 0
|
|
{
|
|
bar.set_position(done.min(total));
|
|
}
|
|
}
|
|
|
|
/// Terminal build view for the pull flow: one bar line attached to the
|
|
/// shared [`MultiProgress`], spinner-style while the work is unsized,
|
|
/// transfer-style once a byte or item total is known. The bar is created
|
|
/// eagerly (an empty spinner, so the line exists from the first moment)
|
|
/// and released on drop.
|
|
pub struct PullView {
|
|
multi: MultiProgress,
|
|
pb: ProgressBar,
|
|
}
|
|
|
|
impl PullView {
|
|
/// Attach a pull view to `multi`
|
|
pub fn new(multi: &MultiProgress) -> Self {
|
|
let pb = multi.add(ProgressBar::new(0));
|
|
pb.enable_steady_tick(Duration::from_millis(50));
|
|
pb.set_style(spinner_style());
|
|
Self {
|
|
multi: multi.clone(),
|
|
pb,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for PullView {
|
|
fn drop(&mut self) {
|
|
self.pb.finish_and_clear();
|
|
self.multi.remove(&self.pb);
|
|
}
|
|
}
|
|
|
|
impl crate::report::BuildView for PullView {
|
|
fn message(&self, text: &str) {
|
|
self.pb.set_style(spinner_style());
|
|
self.pb.set_message(text.to_string());
|
|
}
|
|
|
|
fn progress(&self, label: &str, pos: usize, total: usize) {
|
|
if pos != 0 && total != 0 {
|
|
self.pb.set_style(transfer_style());
|
|
} else {
|
|
self.pb.set_style(spinner_style());
|
|
}
|
|
self.pb.set_message(label.to_string());
|
|
self.pb.set_length(total as u64);
|
|
self.pb.set_position(pos as u64);
|
|
}
|
|
|
|
fn byte_progress(&self, label: &str, pos: u64, total: u64) {
|
|
if pos != 0 && total != 0 {
|
|
self.pb.set_style(byte_style());
|
|
} else {
|
|
self.pb.set_style(spinner_style());
|
|
}
|
|
self.pb.set_message(label.to_string());
|
|
self.pb.set_length(total);
|
|
self.pb.set_position(pos);
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Label of the interactive series selector, rendered by [`prompt::select`]
|
|
/// as `> <label><choice>`
|
|
const SELECT_SERIES_LABEL: &str = "Select series: ";
|
|
|
|
/// Interactive one-line series selector with arrow key navigation and direct typing.
|
|
///
|
|
/// The user can:
|
|
/// - Navigate with ↑/↓ arrow keys to cycle through options
|
|
/// - Type directly to enter a custom release name
|
|
/// - Press Tab to cycle through options matching the current input prefix
|
|
/// - Press Enter to confirm, Escape to use the default
|
|
///
|
|
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
|
|
/// Without a terminal (piped input) or with no options, falls back to the
|
|
/// default.
|
|
pub fn select_series(
|
|
options: &[String],
|
|
default: &str,
|
|
) -> Result<String, Box<dyn std::error::Error>> {
|
|
// Nothing to choose, or no terminal to ask: announce the default and take it
|
|
let fallback = || {
|
|
println!("> {SELECT_SERIES_LABEL}{default} (default)");
|
|
Ok(default.to_string())
|
|
};
|
|
|
|
if options.is_empty() {
|
|
return fallback();
|
|
}
|
|
|
|
match prompt::select(SELECT_SERIES_LABEL, options, default) {
|
|
Ok(series) => Ok(series),
|
|
// prompt::select refuses to answer without a TTY; keep the
|
|
// historical fallback to the default
|
|
Err(err) if err.is::<prompt::PromptError>() => fallback(),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|