Skip to main content

ytil_tui/
lib.rs

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