Skip to main content

ytil_tui/
interactive.rs

1use core::fmt::Debug;
2use core::fmt::Display;
3use std::io::Cursor;
4
5use rootcause::report;
6use skim::Skim;
7use skim::SkimItem;
8use skim::SkimOutput;
9use skim::options::SkimOptions;
10use skim::prelude::SkimItemReader;
11use skim::prelude::SkimItemReaderOption;
12
13/// Provides a minimal interactive multi-select prompt, returning [`Option::None`] if no options are
14/// provided, the user cancels, or no items are selected.
15///
16/// # Errors
17/// - [`skim`] fails to initialize or run.
18pub fn minimal_multi_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<Vec<T>>> {
19    if opts.is_empty() {
20        return Ok(None);
21    }
22
23    let (output, display_texts) = run_skim_prompt(&opts, select_options(true))?;
24    if output.is_abort || output.selected_items.is_empty() {
25        return Ok(None);
26    }
27
28    let mut selected_indices: Vec<usize> = output
29        .selected_items
30        .iter()
31        .filter_map(|mi| find_selected_index(&display_texts, &mi.item))
32        .collect();
33    selected_indices.sort_unstable();
34
35    let mut indexed_opts: Vec<Option<T>> = opts.into_iter().map(Some).collect();
36    let selected: Vec<T> = selected_indices
37        .into_iter()
38        .filter_map(|i| indexed_opts.get_mut(i).and_then(Option::take))
39        .collect();
40
41    if selected.is_empty() {
42        Ok(None)
43    } else {
44        Ok(Some(selected))
45    }
46}
47
48/// Minimal interactive single-select returning [`Option::None`] if `opts` is empty or the user cancels.
49///
50/// # Errors
51/// - [`skim`] fails to initialize or run.
52pub fn minimal_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<T>> {
53    if opts.is_empty() {
54        return Ok(None);
55    }
56
57    let (output, display_texts) = run_skim_prompt(&opts, select_options(false))?;
58    if output.is_abort || output.selected_items.is_empty() {
59        return Ok(None);
60    }
61
62    let index = output
63        .selected_items
64        .first()
65        .and_then(|mi| find_selected_index(&display_texts, &mi.item))
66        .ok_or_else(|| report!("failed to recover selected item index"))?;
67
68    opts.into_iter()
69        .nth(index)
70        .map(Some)
71        .ok_or_else(|| report!("selected index out of bounds").attach(format!("index={index}")))
72}
73
74/// Displays a text input prompt with the given message, allowing cancellation via `Esc` / `Ctrl-C`.
75///
76/// # Errors
77/// - [`skim`] fails to initialize or run.
78pub fn text_prompt(message: &str) -> rootcause::Result<Option<String>> {
79    let Some(output) = run_simple_prompt(simple_prompt_options(message, "3").build(), "")? else {
80        return Ok(None);
81    };
82    let query = output.query.trim().to_owned();
83    if query.is_empty() { Ok(None) } else { Ok(Some(query)) }
84}
85
86/// Displays a yes/no selection prompt.
87///
88/// # Errors
89/// - [`skim`] fails to initialize or run.
90pub fn yes_no_select(title: &str) -> rootcause::Result<Option<bool>> {
91    let options = simple_prompt_options(title, "10%");
92
93    let Some(output) = run_simple_prompt(options.build(), "Yes\nNo")? else {
94        return Ok(None);
95    };
96    if output.selected_items.is_empty() {
97        return Ok(None);
98    }
99
100    let selected_text = output.selected_items.first().map(|mi| mi.item.output().into_owned());
101
102    Ok(Some(selected_text.as_deref() == Some("Yes")))
103}
104
105/// Require exactly one selected item.
106///
107/// # Errors
108/// - More than one item is selected.
109/// - No items are selected.
110pub fn require_single<'a, T>(selected: &'a [T], item_name_plural: &str) -> rootcause::Result<&'a T> {
111    let [item] = selected else {
112        return Err(report!("expected exactly one selection")
113            .attach(format!("item_name_plural={item_name_plural}"))
114            .attach(format!("selected_count={}", selected.len())));
115    };
116    Ok(item)
117}
118
119/// Returns an item derived from CLI args or asks the user to select one.
120///
121/// Priority order:
122/// 1. Tries to find the first CLI arg (by predicate) mapping to an existing item via `item_find_by_arg`.
123/// 2. Falls back to interactive selection ([`minimal_select`]).
124///
125/// # Errors
126/// - A CLI argument matches predicate but no corresponding item is found.
127/// - The interactive selection fails (see [`minimal_select`]).
128pub fn get_item_from_cli_args_or_select<'a, CAS, O, OBA, OF>(
129    cli_args: &'a [String],
130    mut cli_arg_selector: CAS,
131    items: Vec<O>,
132    item_find_by_arg: OBA,
133) -> rootcause::Result<Option<O>>
134where
135    O: Clone + Debug + Display,
136    CAS: FnMut(&(usize, &String)) -> bool,
137    OBA: Fn(&'a str) -> OF,
138    OF: FnMut(&O) -> bool + 'a,
139{
140    if let Some((_, cli_arg)) = cli_args.iter().enumerate().find(|x| cli_arg_selector(x)) {
141        let mut item_find = item_find_by_arg(cli_arg);
142        return Ok(Some(items.iter().find(|x| item_find(*x)).cloned().ok_or_else(
143            || report!("missing item matching CLI arg").attach(format!("cli_arg={cli_arg} items={items:#?}")),
144        )?));
145    }
146    minimal_select(items)
147}
148
149/// Runs [`skim`] with plain-text `input` lines and returns [`Option::None`] on abort.
150fn run_simple_prompt(options: SkimOptions, input: &str) -> rootcause::Result<Option<SkimOutput>> {
151    let items = SkimItemReader::default().of_bufread(Cursor::new(input.to_owned()));
152    let output =
153        Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
154    if output.is_abort {
155        return Ok(None);
156    }
157    Ok(Some(output))
158}
159
160/// Feeds display-text items into [`skim`] via [`SkimItemReader`] and returns the selection output
161/// alongside the original display texts for index recovery.
162fn run_skim_prompt<T: Display>(opts: &[T], options: SkimOptions) -> rootcause::Result<(SkimOutput, Vec<String>)> {
163    let display_texts: Vec<String> = opts.iter().map(ToString::to_string).collect();
164    let input = display_texts.join("\n");
165    let reader_opts = SkimItemReaderOption::from_options(&options);
166    let items = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
167    let output =
168        Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
169    Ok((output, display_texts))
170}
171
172/// Recovers the original source-vector index by matching a selected item's output text against the
173/// display texts collected before running skim.
174fn find_selected_index(display_texts: &[String], item: &std::sync::Arc<dyn SkimItem>) -> Option<usize> {
175    let output_text = item.output();
176    display_texts.iter().position(|t| *t == *output_text)
177}
178
179/// Shared [`SkimOptions`] base: reverse layout, no info line, accept/abort keybindings,
180/// input-order preserved during filtering.
181fn base_skim_options() -> SkimOptions {
182    let mut opts = SkimOptions::default();
183    opts.reverse = true;
184    opts.no_info = true;
185    opts.exact = true;
186    opts.no_sort = true;
187    opts.cycle = true;
188    opts.bind = vec!["enter:accept".into(), "esc:abort".into(), "ctrl-c:abort".into()];
189    opts
190}
191
192/// Lightweight prompt options with a visible prompt string and fixed height.
193fn simple_prompt_options(prompt: &str, height: &str) -> SkimOptions {
194    let mut opts = base_skim_options();
195    opts.height = height.into();
196    opts.prompt = format!("{prompt} ");
197    opts
198}
199
200/// Configures [`SkimOptions`] for single or multi-select mode with ANSI support.
201fn select_options(multi: bool) -> SkimOptions {
202    let mut opts = base_skim_options();
203    opts.multi = multi;
204    opts.ansi = true;
205    opts.height = "40%".into();
206    if multi {
207        opts.bind.extend(["ctrl-e:toggle".into(), "ctrl-a:toggle-all".into()]);
208    }
209    opts.build()
210}
211
212#[cfg(test)]
213mod tests {
214    #[test]
215    fn test_require_single_returns_only_item() {
216        let selected = vec![1];
217        assert2::assert!(let Ok(item) = super::require_single(&selected, "items"));
218        assert_eq!(*item, 1);
219    }
220
221    #[test]
222    fn test_require_single_errors_for_multiple_items() {
223        let selected = vec![1, 2];
224        assert2::assert!(let Err(err) = super::require_single(&selected, "items"));
225        assert!(err.to_string().contains("expected exactly one selection"));
226    }
227}