Files
pkh/src/ui.rs
T
vhaudiquet ae679f29d9
CI / build (push) Successful in 6m18s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Successful in 11m51s
pull,ui,report: render the pull downloads as byte progress
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.
2026-10-03 02:28:22 +02:00

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),
}
}