Revert "[feat] implement centralized UI helpers with cliclack; refactor interactive prompts to improve usability and consistency"
This reverts commit 255be1e413
.
This commit is contained in:
92
Cargo.lock
generated
92
Cargo.lock
generated
@@ -291,20 +291,6 @@ dependencies = [
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cliclack"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8"
|
||||
dependencies = [
|
||||
"console",
|
||||
"indicatif",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"textwrap",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.54"
|
||||
@@ -378,19 +364,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
|
||||
dependencies = [
|
||||
"console",
|
||||
"shell-words",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1171,9 +1144,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"cliclack",
|
||||
"ctrlc",
|
||||
"dialoguer",
|
||||
"indicatif",
|
||||
"libc",
|
||||
"reqwest",
|
||||
@@ -1491,12 +1462,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -1515,12 +1480,6 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.0"
|
||||
@@ -1614,37 +1573,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
"unicode-linebreak",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
@@ -1828,12 +1756,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-linebreak"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.1"
|
||||
@@ -2315,20 +2237,6 @@ name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
@@ -30,8 +30,6 @@ whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", default-features
|
||||
libc = "0.2"
|
||||
indicatif = "0.17"
|
||||
ctrlc = "3.4"
|
||||
dialoguer = "0.11"
|
||||
cliclack = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
27
src/lib.rs
27
src/lib.rs
@@ -245,9 +245,6 @@ pub mod models;
|
||||
/// Progress and progress bar abstraction (TTY-aware, stderr-only)
|
||||
pub mod progress;
|
||||
|
||||
/// UI helpers for interactive prompts (cliclack-backed)
|
||||
pub mod ui;
|
||||
|
||||
/// Transcript entry for a single segment.
|
||||
#[derive(Debug, serde::Serialize, Clone)]
|
||||
pub struct OutputEntry {
|
||||
@@ -518,10 +515,11 @@ where
|
||||
"No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models."
|
||||
));
|
||||
}
|
||||
// Use unified cliclack confirm via UI helper
|
||||
let download_now = crate::ui::prompt_confirm("Download models now?", true)
|
||||
.context("prompt error during confirmation")?;
|
||||
if download_now {
|
||||
printer("Would you like to download models now? [Y/n]:");
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).ok();
|
||||
let ans = input.trim().to_lowercase();
|
||||
if ans.is_empty() || ans == "y" || ans == "yes" {
|
||||
if let Err(e) = models::run_interactive_model_downloader() {
|
||||
elog!("Downloader failed: {:#}", e);
|
||||
}
|
||||
@@ -585,19 +583,20 @@ where
|
||||
// Print a blank line and the selection prompt using the provided printer to
|
||||
// keep output synchronized with any active progress rendering.
|
||||
printer("");
|
||||
let prompt = format!("Select model [1-{}]:", candidates.len());
|
||||
// TODO(ui): migrate to cliclack::Select for model picking to standardize UI.
|
||||
let sel: usize = dialoguer::Input::new()
|
||||
.with_prompt(prompt)
|
||||
.interact_text()
|
||||
printer(&format!("Select model by number [1-{}]:", candidates.len()));
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read selection")?;
|
||||
let sel: usize = input
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Invalid selection: {}", input.trim()))?;
|
||||
if sel == 0 || sel > candidates.len() {
|
||||
return Err(anyhow!("Selection out of range"));
|
||||
}
|
||||
let chosen = candidates.swap_remove(sel - 1);
|
||||
let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string());
|
||||
// Print an empty line after selection input
|
||||
printer("");
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
|
37
src/main.rs
37
src/main.rs
@@ -144,29 +144,38 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool,
|
||||
// Explicitly non-interactive: never prompt
|
||||
return default_name.to_string();
|
||||
}
|
||||
|
||||
let display_owned: String = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||
|
||||
// Render prompt above any progress bars
|
||||
// Synchronized prompt above any progress bars
|
||||
pm.pause_for_prompt();
|
||||
let answer = {
|
||||
let prompt = format!("Enter speaker name for {} [default: {}]", display_owned, default_name);
|
||||
match polyscribe::ui::prompt_text(&prompt, default_name) {
|
||||
Ok(ans) => ans,
|
||||
Err(_) => default_name.to_string(),
|
||||
}
|
||||
};
|
||||
pm.println_above_bars(&format!(
|
||||
"Enter speaker name for {} [default: {}]:",
|
||||
display_owned, default_name
|
||||
));
|
||||
|
||||
let mut buf = String::new();
|
||||
let res = io::stdin().read_line(&mut buf);
|
||||
pm.resume_after_prompt();
|
||||
|
||||
let sanitized = sanitize_speaker_name(&answer);
|
||||
if sanitized.is_empty() {
|
||||
default_name.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let raw = buf.trim();
|
||||
if raw.is_empty() {
|
||||
return default_name.to_string();
|
||||
}
|
||||
let sanitized = sanitize_speaker_name(raw);
|
||||
if sanitized.is_empty() {
|
||||
default_name.to_string()
|
||||
} else {
|
||||
// Defer echoing of the chosen name; caller will print a permanent line later
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
Err(_) => default_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
162
src/models.rs
162
src/models.rs
@@ -393,62 +393,130 @@ fn format_model_list(models: &[ModelEntry]) -> String {
|
||||
}
|
||||
|
||||
fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntry>> {
|
||||
// Non-interactive safeguard: return empty (caller will handle as cancel/skip)
|
||||
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||
// Non-interactive: do not prompt, return empty selection to skip
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if models.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Stage 1: pick a base family; preserve order from input list
|
||||
// 1) Choose base (tiny, small, medium, etc.)
|
||||
let mut bases: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut last = String::new();
|
||||
for m in models.iter() {
|
||||
if !seen.contains(&m.base) {
|
||||
seen.insert(m.base.clone());
|
||||
bases.push(m.base.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let base = if bases.len() == 1 {
|
||||
bases[0].clone()
|
||||
} else {
|
||||
crate::ui::prompt_select_one("Select model family/base:", &bases)?
|
||||
};
|
||||
|
||||
// Stage 2: within base, present variants
|
||||
let mut variants: Vec<&ModelEntry> = models.iter().filter(|m| m.base == base).collect();
|
||||
variants.sort_by_key(|m| (m.size, m.subtype.clone(), m.name.clone()));
|
||||
|
||||
let labels: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let size_h = human_size(m.size);
|
||||
if let Some(sha) = &m.sha256 {
|
||||
format!("{} ({}, {}, sha: {}…)", m.name, m.subtype, size_h, &sha[..std::cmp::min(8, sha.len())])
|
||||
} else {
|
||||
format!("{} ({}, {})", m.name, m.subtype, size_h)
|
||||
if m.base != last {
|
||||
// models are sorted by base; avoid duplicates while preserving order
|
||||
if !bases.last().map(|b| b == &m.base).unwrap_or(false) {
|
||||
bases.push(m.base.clone());
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let selected_labels = crate::ui::prompt_multiselect(
|
||||
"Select one or more variants to download:",
|
||||
&labels,
|
||||
&[],
|
||||
)?;
|
||||
|
||||
// Map labels back to entries in stable order
|
||||
let mut picked: Vec<ModelEntry> = Vec::new();
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
if selected_labels.iter().any(|s| s == label) {
|
||||
picked.push(variants[i].clone().clone());
|
||||
last = m.base.clone();
|
||||
}
|
||||
}
|
||||
if bases.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(picked)
|
||||
// Print base selection on stderr
|
||||
eprintln!("Available base model families:");
|
||||
for (i, b) in bases.iter().enumerate() {
|
||||
eprintln!(" {}) {}", i + 1, b);
|
||||
}
|
||||
loop {
|
||||
eprint!("Select base (number or name, 'q' to cancel): ");
|
||||
io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut line)
|
||||
.context("Failed to read base selection")?;
|
||||
let s = line.trim();
|
||||
if s.eq_ignore_ascii_case("q")
|
||||
|| s.eq_ignore_ascii_case("quit")
|
||||
|| s.eq_ignore_ascii_case("exit")
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let chosen_base = if let Ok(i) = s.parse::<usize>() {
|
||||
if i >= 1 && i <= bases.len() {
|
||||
Some(bases[i - 1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if !s.is_empty() {
|
||||
// accept exact name match (case-insensitive)
|
||||
bases.iter().find(|b| b.eq_ignore_ascii_case(s)).cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(base) = chosen_base {
|
||||
// 2) Choose sub-type(s) within that base
|
||||
let filtered: Vec<ModelEntry> =
|
||||
models.iter().filter(|m| m.base == base).cloned().collect();
|
||||
if filtered.is_empty() {
|
||||
eprintln!("No models found for base '{base}'.");
|
||||
continue;
|
||||
}
|
||||
// Reuse the formatter but only for the chosen base list
|
||||
let listing = format_model_list(&filtered);
|
||||
eprint!("{listing}");
|
||||
|
||||
// Build index map for filtered list
|
||||
let mut index_map: Vec<usize> = Vec::with_capacity(filtered.len());
|
||||
let mut idx = 1usize;
|
||||
for (pos, _m) in filtered.iter().enumerate() {
|
||||
index_map.push(pos);
|
||||
idx += 1;
|
||||
}
|
||||
// Second prompt: sub-type selection
|
||||
loop {
|
||||
eprint!("Selection: ");
|
||||
io::stderr().flush().ok();
|
||||
let mut line2 = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut line2)
|
||||
.context("Failed to read selection")?;
|
||||
let s2 = line2.trim().to_lowercase();
|
||||
if s2 == "q" || s2 == "quit" || s2 == "exit" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut selected: Vec<usize> = Vec::new();
|
||||
if s2 == "all" || s2 == "*" {
|
||||
selected = (1..idx).collect();
|
||||
} else if !s2.is_empty() {
|
||||
for part in s2.split([',', ' ', ';']) {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((a, b)) = part.split_once('-') {
|
||||
if let (Ok(ia), Ok(ib)) = (a.parse::<usize>(), b.parse::<usize>()) {
|
||||
if ia >= 1 && ib < idx && ia <= ib {
|
||||
selected.extend(ia..=ib);
|
||||
}
|
||||
}
|
||||
} else if let Ok(i) = part.parse::<usize>() {
|
||||
if i >= 1 && i < idx {
|
||||
selected.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.sort_unstable();
|
||||
selected.dedup();
|
||||
if selected.is_empty() {
|
||||
eprintln!("No valid selection. Please try again or 'q' to cancel.");
|
||||
continue;
|
||||
}
|
||||
let chosen: Vec<ModelEntry> = selected
|
||||
.into_iter()
|
||||
.map(|i| filtered[index_map[i - 1]].clone())
|
||||
.collect();
|
||||
return Ok(chosen);
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Invalid base selection. Please enter a number from 1-{} or a base name.",
|
||||
bases.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_file_sha256_hex(path: &Path) -> Result<String> {
|
||||
|
@@ -124,8 +124,6 @@ enum ProgressInner {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SingleBars {
|
||||
header: ProgressBar,
|
||||
info: ProgressBar,
|
||||
current: ProgressBar,
|
||||
// keep MultiProgress alive for suspend/println behavior
|
||||
_mp: Arc<MultiProgress>,
|
||||
@@ -133,14 +131,10 @@ struct SingleBars {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MultiBars {
|
||||
// Header row shown above bars
|
||||
header: ProgressBar,
|
||||
// Single info/status row shown under header and above bars
|
||||
info: ProgressBar,
|
||||
// Bars: current file and total
|
||||
current: ProgressBar,
|
||||
// Legacy bars for compatibility (used when not using per-file init)
|
||||
total: ProgressBar,
|
||||
// Optional per-file bars and aggregated total percent bar (unused in new UX)
|
||||
current: ProgressBar,
|
||||
// Optional per-file bars and aggregated total percent bar
|
||||
files: Mutex<Option<Vec<ProgressBar>>>, // each length 100
|
||||
total_pct: Mutex<Option<ProgressBar>>, // length 100
|
||||
// Metadata for aggregation
|
||||
@@ -212,34 +206,24 @@ impl ProgressManager {
|
||||
}
|
||||
|
||||
fn with_single(mp: Arc<MultiProgress>) -> Self {
|
||||
// Order: header, info row, then current file bar
|
||||
let header = mp.add(ProgressBar::new(0));
|
||||
header.set_style(info_style());
|
||||
let info = mp.add(ProgressBar::new(0));
|
||||
info.set_style(info_style());
|
||||
let current = mp.add(ProgressBar::new(100));
|
||||
current.set_style(current_style());
|
||||
current.set_style(spinner_style());
|
||||
Self {
|
||||
inner: ProgressInner::Single(Arc::new(SingleBars { header, info, current, _mp: mp })),
|
||||
inner: ProgressInner::Single(Arc::new(SingleBars { current, _mp: mp })),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_multi(mp: Arc<MultiProgress>, total_inputs: u64) -> Self {
|
||||
// Order: header, info row, then current file bar, then total bar at the bottom
|
||||
let header = mp.add(ProgressBar::new(0));
|
||||
header.set_style(info_style());
|
||||
let info = mp.add(ProgressBar::new(0));
|
||||
info.set_style(info_style());
|
||||
// Add current first, then total so that total stays anchored at the bottom line
|
||||
let current = mp.add(ProgressBar::new(100));
|
||||
current.set_style(current_style());
|
||||
current.set_style(spinner_style());
|
||||
let total = mp.add(ProgressBar::new(total_inputs));
|
||||
total.set_style(total_style());
|
||||
total.set_message("total");
|
||||
Self {
|
||||
inner: ProgressInner::Multi(Arc::new(MultiBars {
|
||||
header,
|
||||
info,
|
||||
current,
|
||||
total,
|
||||
current,
|
||||
files: Mutex::new(None),
|
||||
total_pct: Mutex::new(None),
|
||||
sizes: Mutex::new(None),
|
||||
@@ -446,19 +430,15 @@ impl ProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_style() -> ProgressStyle {
|
||||
// Per-item determinate progress: show 0..100 as pos/len with a simple bar
|
||||
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {pos}/{len} {bar:40.cyan/blue} {msg}")
|
||||
.expect("invalid progress template in current_style()")
|
||||
}
|
||||
|
||||
fn info_style() -> ProgressStyle {
|
||||
ProgressStyle::with_template("{msg}").unwrap()
|
||||
fn spinner_style() -> ProgressStyle {
|
||||
// Style for per-item determinate progress: 0-100% with a compact bar and message
|
||||
ProgressStyle::with_template("{bar:24.green/green} {percent:>3}% {msg}")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn total_style() -> ProgressStyle {
|
||||
// Bottom total bar with elapsed time
|
||||
ProgressStyle::with_template("Total [{bar:28=> }] {pos}/{len} [{elapsed_precise}]").unwrap()
|
||||
// Persistent bottom bar showing total completed/total inputs
|
||||
ProgressStyle::with_template("{bar:40.cyan/blue} {pos}/{len} {msg}").unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -535,7 +515,7 @@ impl ProgressManager {
|
||||
for (label_in, size_opt) in labels_and_sizes {
|
||||
let label: String = label_in.into();
|
||||
let pb = m._mp.add(ProgressBar::new(100));
|
||||
pb.set_style(current_style());
|
||||
pb.set_style(spinner_style());
|
||||
let short = truncate_label(&label, NAME_WIDTH);
|
||||
pb.set_message(format!("{:<width$}", short, width = NAME_WIDTH));
|
||||
files.push(pb);
|
||||
|
93
src/ui.rs
93
src/ui.rs
@@ -1,93 +0,0 @@
|
||||
// Centralized UI helpers for interactive prompts.
|
||||
// Uses cliclack for consistent TTY-friendly UX.
|
||||
//
|
||||
// If you need a new prompt type, add it here so callers don't depend on a specific library.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
/// Prompt the user for a free-text value with a default fallback.
|
||||
///
|
||||
/// - Uses cliclack Input to render a TTY-friendly prompt.
|
||||
/// - Returns `default` when the user submits an empty value.
|
||||
/// - On any prompt error (e.g., non-TTY, read error), returns an error; callers should
|
||||
/// handle it and typically fall back to `default` in non-interactive contexts.
|
||||
pub fn prompt_text(prompt: &str, default: &str) -> Result<String> {
|
||||
let res: Result<String, _> = cliclack::input(prompt)
|
||||
.default_input(default)
|
||||
.interact();
|
||||
let value = res.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||
|
||||
let trimmed = value.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask for yes/no confirmation with a default choice.
|
||||
///
|
||||
/// Returns the selected boolean. Any underlying prompt error is returned as an error.
|
||||
pub fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||
let res: Result<bool, _> = cliclack::confirm(prompt)
|
||||
.initial_value(default)
|
||||
.interact();
|
||||
res.map_err(|e| anyhow!("prompt error: {e}"))
|
||||
}
|
||||
|
||||
/// Single-select from a list of displayable items, returning the selected index.
|
||||
///
|
||||
/// - `items`: non-empty slice of displayable items.
|
||||
/// - Returns the index into `items`.
|
||||
pub fn prompt_select_index<T: std::fmt::Display>(prompt: &str, items: &[T]) -> Result<usize> {
|
||||
if items.is_empty() {
|
||||
return Err(anyhow!("prompt_select_index called with empty items"));
|
||||
}
|
||||
let mut sel = cliclack::select(prompt);
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
sel = sel.item(i, format!("{}", it), "");
|
||||
}
|
||||
let idx: usize = sel
|
||||
.interact()
|
||||
.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Single-select from a list of clonable displayable items, returning the chosen item.
|
||||
pub fn prompt_select_one<T: std::fmt::Display + Clone>(prompt: &str, items: &[T]) -> Result<T> {
|
||||
let idx = prompt_select_index(prompt, items)?;
|
||||
Ok(items[idx].clone())
|
||||
}
|
||||
|
||||
/// Multi-select from a list, returning the selected indices.
|
||||
///
|
||||
/// - `defaults`: indices that should be pre-selected.
|
||||
pub fn prompt_multiselect_indices<T: std::fmt::Display>(
|
||||
prompt: &str,
|
||||
items: &[T],
|
||||
defaults: &[usize],
|
||||
) -> Result<Vec<usize>> {
|
||||
if items.is_empty() {
|
||||
return Err(anyhow!("prompt_multiselect_indices called with empty items"));
|
||||
}
|
||||
let mut ms = cliclack::multiselect(prompt);
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
ms = ms.item(i, format!("{}", it), "");
|
||||
}
|
||||
let indices: Vec<usize> = ms
|
||||
.initial_values(defaults.to_vec())
|
||||
.required(false)
|
||||
.interact()
|
||||
.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// Multi-select from a list, returning the chosen items in order of appearance.
|
||||
pub fn prompt_multiselect<T: std::fmt::Display + Clone>(
|
||||
prompt: &str,
|
||||
items: &[T],
|
||||
defaults: &[usize],
|
||||
) -> Result<Vec<T>> {
|
||||
let indices = prompt_multiselect_indices(prompt, items, defaults)?;
|
||||
Ok(indices.into_iter().map(|i| items[i].clone()).collect())
|
||||
}
|
Reference in New Issue
Block a user