ytil_tui/
lib.rs

1//! Provide minimal TUI selection & prompt helpers built on `inquire`.
2//!
3//! Offer uniform, cancellable single / multi select prompts with stripped visual noise and helpers
4//! to derive a value from CLI args or fallback to an interactive selector.
5
6use core::fmt::Debug;
7use core::fmt::Display;
8
9use color_eyre::eyre::eyre;
10use inquire::InquireError;
11use inquire::MultiSelect;
12use inquire::Select;
13use inquire::Text;
14use inquire::ui::RenderConfig;
15use strum::EnumIter;
16use strum::IntoEnumIterator;
17
18pub mod git_branch;
19
20/// Provides a minimal interactive multi-select prompt, returning [`Option::None`] if no options are provided, the user
21/// cancels, or no items are selected.
22///
23/// Wraps [`inquire::MultiSelect`] with a slim rendering (see `minimal_render_config`) and no help message.
24///
25/// # Type Parameters
26/// - `T` The type of the options, constrained to implement [`Display`].
27///
28/// # Errors
29/// - [`InquireError`]: Propagated from [`inquire`] for failures in prompt rendering or user interaction, excluding
30///   cancellation which is handled as [`None`].
31pub fn minimal_multi_select<T: Display>(opts: Vec<T>) -> Result<Option<Vec<T>>, InquireError> {
32    if opts.is_empty() {
33        return Ok(None);
34    }
35    let Some(selected_opts) = closable_prompt(
36        MultiSelect::new("", opts)
37            .with_render_config(minimal_render_config())
38            .without_help_message()
39            .prompt(),
40    )?
41    else {
42        return Ok(None);
43    };
44    if selected_opts.is_empty() {
45        return Ok(None);
46    }
47    Ok(Some(selected_opts))
48}
49
50/// Minimal interactive single-select returning [`Option::None`] if `opts` is empty or the user cancels.
51///
52/// Wraps [`inquire::Select`] with a slim rendering (see `minimal_render_config`) and no help message.
53///
54/// # Errors
55/// - Rendering the prompt or terminal interaction inside [`inquire`] fails.
56/// - Collecting the user selection fails for any reason reported by [`Select`].
57pub fn minimal_select<T: Display>(opts: Vec<T>) -> Result<Option<T>, InquireError> {
58    if opts.is_empty() {
59        return Ok(None);
60    }
61    closable_prompt(
62        Select::new("", opts)
63            .with_render_config(minimal_render_config())
64            .without_help_message()
65            .prompt(),
66    )
67}
68
69/// Displays a text input prompt with the given message, allowing cancellation.
70///
71/// Wraps [`inquire::Text`] with minimal rendering and cancellation handling.
72///
73/// # Errors
74/// - Rendering the prompt or terminal interaction inside [`inquire`] fails.
75/// - Collecting the user input fails for any reason reported by [`Text`].
76pub fn text_prompt(message: &str) -> Result<Option<String>, InquireError> {
77    closable_prompt(Text::new(message).prompt())
78}
79
80/// Displays a yes/no selection prompt with a minimal UI.
81///
82/// Returns [`Result::Ok`] ([`Option::Some`] (_)) on selection, [`Result::Ok`] ([`Option::None`]) if
83/// canceled/interrupted.
84///
85/// # Errors
86/// - Rendering the prompt or terminal interaction inside [`inquire`] fails.
87pub fn yes_no_select(title: &str) -> Result<Option<bool>, InquireError> {
88    closable_prompt(
89        Select::new(title, YesNo::iter().collect())
90            .with_render_config(minimal_render_config())
91            .without_help_message()
92            .prompt()
93            .map(From::from),
94    )
95}
96
97/// Represents a yes or no choice for user selection.
98#[derive(Clone, Copy, Debug, EnumIter)]
99enum YesNo {
100    Yes,
101    No,
102}
103
104impl From<YesNo> for bool {
105    fn from(value: YesNo) -> Self {
106        match value {
107            YesNo::Yes => true,
108            YesNo::No => false,
109        }
110    }
111}
112
113impl Display for YesNo {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let repr = match self {
116            Self::Yes => "Yes",
117            Self::No => "No",
118        };
119        write!(f, "{repr}")
120    }
121}
122
123/// Returns an item derived from CLI args or asks the user to select one.
124///
125/// Priority order:
126/// 1. Tries to find the first CLI arg (by predicate) mapping to an existing item via `item_find_by_arg`.
127/// 2. Falls back to interactive selection (`minimal_select`).
128///
129/// Generic over a collection of displayable, cloneable items, so callers can pass any vector of choices.
130///
131/// # Type Parameters
132/// - `CAS` Closure filtering `(index, &String)` CLI arguments.
133/// - `OBA` Closure mapping an argument `&str` into a predicate over `&O`.
134/// - `OF` Predicate produced by `OBA` used to match an item.
135///
136/// # Errors
137/// - A CLI argument matches predicate but no corresponding item is found.
138/// - The interactive selection fails (see [`minimal_select`]).
139pub fn get_item_from_cli_args_or_select<'a, CAS, O, OBA, OF>(
140    cli_args: &'a [String],
141    mut cli_arg_selector: CAS,
142    items: Vec<O>,
143    item_find_by_arg: OBA,
144) -> color_eyre::Result<Option<O>>
145where
146    O: Clone + Debug + Display,
147    CAS: FnMut(&(usize, &String)) -> bool,
148    OBA: Fn(&'a str) -> OF,
149    OF: FnMut(&O) -> bool + 'a,
150{
151    if let Some((_, cli_arg)) = cli_args.iter().enumerate().find(|x| cli_arg_selector(x)) {
152        let mut item_find = item_find_by_arg(cli_arg);
153        return Ok(Some(items.iter().find(|x| item_find(*x)).cloned().ok_or_else(
154            || eyre!("missing item matching CLI arg | cli_arg={cli_arg} items={items:#?}"),
155        )?));
156    }
157    Ok(minimal_select(items)?)
158}
159
160/// Converts an [`inquire`] prompt [`Result`] into an [`Option`]-wrapped [`Result`].
161///
162/// Treats [`InquireError::OperationCanceled`] / [`InquireError::OperationInterrupted`] as [`Result::Ok`]
163/// ([`Option::None`]).
164fn closable_prompt<T>(prompt_res: Result<T, InquireError>) -> Result<Option<T>, InquireError> {
165    match prompt_res {
166        Ok(res) => Ok(Some(res)),
167        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
168        Err(err) => Err(err),
169    }
170}
171
172/// Returns a minimalist [`RenderConfig`] with cleared prompt/answered prefixes.
173fn minimal_render_config<'a>() -> RenderConfig<'a> {
174    RenderConfig::default_colored()
175        .with_prompt_prefix("".into())
176        .with_canceled_prompt_indicator("".into())
177        .with_answered_prompt_prefix("".into())
178}