Skip to main content

ytil_tui/
interactive.rs

1use std::borrow::Cow;
2use std::fmt::Debug;
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::io::Cursor;
6use std::rc::Rc;
7use std::sync::Arc;
8
9use ratatui::text::Line;
10use rootcause::report;
11use skim::DisplayContext;
12use skim::MatchEngine;
13use skim::MatchEngineFactory;
14use skim::MatchRange;
15use skim::MatchResult;
16use skim::Skim;
17use skim::SkimItem;
18use skim::SkimItemReceiver;
19use skim::SkimOutput;
20use skim::options::SkimOptions;
21use skim::prelude::SkimItemReader;
22use skim::prelude::SkimItemReaderOption;
23
24/// Provides a minimal interactive multi-select prompt.
25///
26/// Returns [`Option::None`] if no options are provided, the user cancels, or no items are selected.
27/// Matching uses `search_text`, while rendering uses `display_text`.
28///
29/// # Errors
30/// - [`skim`] fails to initialize or run.
31pub fn minimal_multi_select<T, D, S>(
32    opts: Vec<T>,
33    mut display_text: D,
34    mut search_text: S,
35) -> rootcause::Result<Option<Vec<T>>>
36where
37    D: FnMut(&T) -> String,
38    S: FnMut(&T) -> String,
39{
40    if opts.is_empty() {
41        return Ok(None);
42    }
43
44    let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
45    let display_texts: Vec<String> = opts.iter().map(|opt| normalize(&display_text(opt))).collect();
46    let display_items = build_ansi_display_items(&display_texts)?;
47    let items: Vec<Arc<dyn SkimItem>> = opts
48        .iter()
49        .enumerate()
50        .map(|(index, opt)| {
51            let display_item = Arc::clone(display_items.get(index)?);
52            let visible_match_text = display_item.text().into_owned();
53            let hidden_search = normalize(&search_text(opt));
54            let search_corpus = if hidden_search.is_empty() || hidden_search == visible_match_text {
55                visible_match_text.clone()
56            } else {
57                format!("{visible_match_text} {hidden_search}")
58            };
59
60            Some(Arc::new(IndexedSkimItem {
61                output: index.to_string(),
62                display_item,
63                visible_text: visible_match_text,
64                search_corpus,
65            }) as Arc<dyn SkimItem>)
66        })
67        .collect::<Option<Vec<_>>>()
68        .ok_or_else(|| report!("missing ANSI display item while building skim rows"))?;
69
70    let (tx_items, rx_items) = skim::prelude::unbounded();
71    tx_items
72        .send(items)
73        .map_err(|e| report!("failed to queue skim items").attach(e.to_string()))?;
74    drop(tx_items);
75
76    let options = select_options(true);
77    let output = run_skim_with_matcher(options, rx_items)?;
78
79    if output.is_abort || output.selected_items.is_empty() {
80        return Ok(None);
81    }
82
83    let mut selected_indices: Vec<usize> = output
84        .selected_items
85        .iter()
86        .filter_map(|mi| mi.item.output().parse().ok())
87        .collect();
88    selected_indices.sort_unstable();
89    selected_indices.dedup();
90
91    let mut indexed_opts: Vec<Option<T>> = opts.into_iter().map(Some).collect();
92    let selected: Vec<T> = selected_indices
93        .into_iter()
94        .filter_map(|i| indexed_opts.get_mut(i).and_then(Option::take))
95        .collect();
96
97    if selected.is_empty() {
98        Ok(None)
99    } else {
100        Ok(Some(selected))
101    }
102}
103
104/// Minimal interactive single-select returning [`Option::None`] if `opts` is empty or the user cancels.
105///
106/// # Errors
107/// - [`skim`] fails to initialize or run.
108pub fn minimal_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<T>> {
109    if opts.is_empty() {
110        return Ok(None);
111    }
112
113    let (output, display_texts) = run_skim_prompt(&opts, select_options(false))?;
114    if output.is_abort || output.selected_items.is_empty() {
115        return Ok(None);
116    }
117
118    let index = output
119        .selected_items
120        .first()
121        .and_then(|mi| {
122            let output_text = mi.item.output();
123            display_texts.iter().position(|t| *t == *output_text)
124        })
125        .ok_or_else(|| report!("failed to recover selected item index"))?;
126
127    opts.into_iter()
128        .nth(index)
129        .map(Some)
130        .ok_or_else(|| report!("selected index out of bounds").attach(format!("index={index}")))
131}
132
133/// Displays a text input prompt with the given message, allowing cancellation via `Esc` / `Ctrl-C`.
134///
135/// # Errors
136/// - [`skim`] fails to initialize or run.
137pub fn text_prompt(message: &str) -> rootcause::Result<Option<String>> {
138    let Some(output) = run_simple_prompt(simple_prompt_options(message, "3").build(), "")? else {
139        return Ok(None);
140    };
141    let query = output.query.trim().to_owned();
142    if query.is_empty() { Ok(None) } else { Ok(Some(query)) }
143}
144
145/// Displays a yes/no selection prompt.
146///
147/// # Errors
148/// - [`skim`] fails to initialize or run.
149pub fn yes_no_select(title: &str) -> rootcause::Result<Option<bool>> {
150    let options = simple_prompt_options(title, "10%");
151
152    let Some(output) = run_simple_prompt(options.build(), "Yes\nNo")? else {
153        return Ok(None);
154    };
155    if output.selected_items.is_empty() {
156        return Ok(None);
157    }
158
159    let selected_text = output.selected_items.first().map(|mi| mi.item.output().into_owned());
160
161    Ok(Some(selected_text.as_deref() == Some("Yes")))
162}
163
164/// Require exactly one selected item.
165///
166/// # Errors
167/// - More than one item is selected.
168/// - No items are selected.
169pub fn require_single<'a, T>(selected: &'a [T], item_name_plural: &str) -> rootcause::Result<&'a T> {
170    let [item] = selected else {
171        return Err(report!("expected exactly one selection")
172            .attach(format!("item_name_plural={item_name_plural}"))
173            .attach(format!("selected_count={}", selected.len())));
174    };
175    Ok(item)
176}
177
178/// Returns an item derived from CLI args or asks the user to select one.
179///
180/// Priority order:
181/// 1. Tries to find the first CLI arg (by predicate) mapping to an existing item via `item_find_by_arg`.
182/// 2. Falls back to interactive selection ([`minimal_select`]).
183///
184/// # Errors
185/// - A CLI argument matches predicate but no corresponding item is found.
186/// - The interactive selection fails (see [`minimal_select`]).
187pub fn get_item_from_cli_args_or_select<'a, CAS, O, OBA, OF>(
188    cli_args: &'a [String],
189    mut cli_arg_selector: CAS,
190    items: Vec<O>,
191    item_find_by_arg: OBA,
192) -> rootcause::Result<Option<O>>
193where
194    O: Clone + Debug + Display,
195    CAS: FnMut(&(usize, &String)) -> bool,
196    OBA: Fn(&'a str) -> OF,
197    OF: FnMut(&O) -> bool + 'a,
198{
199    let Some((_, cli_arg)) = cli_args.iter().enumerate().find(|x| cli_arg_selector(x)) else {
200        return minimal_select(items);
201    };
202    let mut item_find = item_find_by_arg(cli_arg);
203    Ok(Some(items.iter().find(|x| item_find(*x)).cloned().ok_or_else(
204        || report!("missing item matching CLI arg").attach(format!("cli_arg={cli_arg} items={items:#?}")),
205    )?))
206}
207
208#[derive(Debug)]
209struct IndexedSkimItem {
210    output: String,
211    display_item: Arc<dyn SkimItem>,
212    visible_text: String,
213    search_corpus: String,
214}
215
216impl SkimItem for IndexedSkimItem {
217    fn text(&self) -> Cow<'_, str> {
218        Cow::Borrowed(&self.visible_text)
219    }
220
221    fn display(&self, context: DisplayContext) -> Line<'_> {
222        self.display_item.display(context)
223    }
224
225    fn output(&self) -> Cow<'_, str> {
226        Cow::Borrowed(&self.output)
227    }
228}
229
230struct SearchCorpusEngineFactory {
231    inner: Rc<dyn MatchEngineFactory>,
232}
233
234impl SearchCorpusEngineFactory {
235    fn new(inner: Rc<dyn MatchEngineFactory>) -> Self {
236        Self { inner }
237    }
238}
239
240impl MatchEngineFactory for SearchCorpusEngineFactory {
241    fn create_engine_with_case(&self, query: &str, case: skim::CaseMatching) -> Box<dyn MatchEngine> {
242        Box::new(SearchCorpusEngine {
243            inner: self.inner.create_engine_with_case(query, case),
244        })
245    }
246}
247
248struct SearchCorpusEngine {
249    inner: Box<dyn MatchEngine>,
250}
251
252impl Display for SearchCorpusEngine {
253    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
254        write!(f, "{}", self.inner)
255    }
256}
257
258impl MatchEngine for SearchCorpusEngine {
259    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
260        let Some(item) = item.as_any().downcast_ref::<IndexedSkimItem>() else {
261            return self.inner.match_item(item);
262        };
263
264        let mut result = self.inner.match_item(&item.search_corpus)?;
265        result.matched_range = clip_match_range(result.matched_range, item);
266        Some(result)
267    }
268}
269
270fn clip_match_range(match_range: MatchRange, item: &IndexedSkimItem) -> MatchRange {
271    let visible_char_len = item.visible_text.chars().count();
272    let visible_byte_len = item.visible_text.len();
273
274    match match_range {
275        MatchRange::Chars(indices) => {
276            MatchRange::Chars(indices.into_iter().filter(|index| *index < visible_char_len).collect())
277        }
278        MatchRange::CharRange(start, end) => {
279            if start >= visible_char_len {
280                MatchRange::Chars(Vec::new())
281            } else {
282                MatchRange::CharRange(start, end.min(visible_char_len))
283            }
284        }
285        MatchRange::ByteRange(start, end) => {
286            if start >= visible_byte_len {
287                MatchRange::Chars(Vec::new())
288            } else {
289                MatchRange::ByteRange(start, end.min(visible_byte_len))
290            }
291        }
292    }
293}
294
295fn run_skim_with_matcher(options: SkimOptions, source: SkimItemReceiver) -> rootcause::Result<SkimOutput> {
296    let (engine_factory, rank_builder) = skim::matcher::Matcher::create_engine_factory_with_builder(&options);
297    let matcher = skim::matcher::Matcher::builder(Rc::new(SearchCorpusEngineFactory::new(engine_factory)))
298        .case(options.case)
299        .rank_builder(rank_builder)
300        .build();
301
302    let mut skim = skim::Skim::init(options, Some(source))
303        .map_err(|e| report!("skim failed to initialize").attach(e.to_string()))?;
304    skim.app_mut().matcher = matcher;
305    skim.start();
306
307    if skim.should_enter() {
308        skim.init_tui()
309            .map_err(|e| report!("skim failed to initialize TUI").attach(e.to_string()))?;
310
311        let task = async {
312            skim.enter().await.map_err(|e| e.to_string())?;
313            skim.run().await.map_err(|e| e.to_string())?;
314            Ok::<(), String>(())
315        };
316
317        if let Ok(handle) = tokio::runtime::Handle::try_current() {
318            tokio::task::block_in_place(|| handle.block_on(task))
319                .map_err(|e| report!("skim failed to run").attach(e))?;
320        } else {
321            tokio::runtime::Runtime::new()
322                .map_err(|e| report!("failed to create tokio runtime").attach(e.to_string()))?
323                .block_on(task)
324                .map_err(|e| report!("skim failed to run").attach(e))?;
325        }
326    }
327
328    Ok(skim.output())
329}
330
331fn build_ansi_display_items(display_texts: &[String]) -> rootcause::Result<Vec<Arc<dyn SkimItem>>> {
332    let input = display_texts.join("\n");
333
334    let reader_opts = SkimItemReaderOption::default().ansi(true).build();
335    let receiver = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
336    let mut items = Vec::with_capacity(display_texts.len());
337    while let Ok(batch) = receiver.recv() {
338        items.extend(batch);
339    }
340
341    if items.len() != display_texts.len() {
342        return Err(report!("failed to build ANSI display items")
343            .attach(format!("expected={}", display_texts.len()))
344            .attach(format!("actual={}", items.len())));
345    }
346    Ok(items)
347}
348
349/// Runs [`skim`] with plain-text `input` lines and returns [`Option::None`] on abort.
350fn run_simple_prompt(options: SkimOptions, input: &str) -> rootcause::Result<Option<SkimOutput>> {
351    let items = SkimItemReader::default().of_bufread(Cursor::new(input.to_owned()));
352    let output =
353        Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
354    if output.is_abort {
355        return Ok(None);
356    }
357    Ok(Some(output))
358}
359
360/// Feeds display-text items into [`skim`] via [`SkimItemReader`] and returns the selection output
361/// alongside the original display texts for index recovery.
362fn run_skim_prompt<T: Display>(opts: &[T], options: SkimOptions) -> rootcause::Result<(SkimOutput, Vec<String>)> {
363    let display_texts: Vec<String> = opts.iter().map(ToString::to_string).collect();
364    let input = display_texts.join("\n");
365    let reader_opts = SkimItemReaderOption::from_options(&options);
366    let items = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
367    let output =
368        Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
369    Ok((output, display_texts))
370}
371
372/// Shared [`SkimOptions`] base: reverse layout, no info line, accept/abort keybindings,
373/// input-order preserved during filtering.
374fn base_skim_options() -> SkimOptions {
375    let mut opts = SkimOptions::default();
376    opts.reverse = true;
377    opts.no_info = true;
378    opts.exact = true;
379    opts.no_sort = true;
380    opts.cycle = true;
381    opts.bind = vec!["enter:accept".into(), "esc:abort".into(), "ctrl-c:abort".into()];
382    opts
383}
384
385/// Lightweight prompt options with a visible prompt string and fixed height.
386fn simple_prompt_options(prompt: &str, height: &str) -> SkimOptions {
387    let mut opts = base_skim_options();
388    opts.height = height.into();
389    opts.prompt = format!("{prompt} ");
390    opts
391}
392
393/// Configures [`SkimOptions`] for single or multi-select mode with ANSI support.
394fn select_options(multi: bool) -> SkimOptions {
395    let mut opts = base_skim_options();
396    opts.multi = multi;
397    opts.ansi = true;
398    opts.height = "40%".into();
399    if multi {
400        opts.bind.extend(["ctrl-e:toggle".into(), "ctrl-a:toggle-all".into()]);
401    }
402    opts.build()
403}
404
405#[cfg(test)]
406mod tests {
407    use std::sync::Arc;
408
409    use skim::DisplayContext;
410    use skim::MatchRange;
411    use skim::SkimItem;
412
413    #[test]
414    fn test_require_single_returns_only_item() {
415        let selected = vec![1];
416        assert2::assert!(let Ok(item) = super::require_single(&selected, "items"));
417        pretty_assertions::assert_eq!(*item, 1);
418    }
419
420    #[test]
421    fn test_require_single_errors_for_multiple_items() {
422        let selected = vec![1, 2];
423        assert2::assert!(let Err(err) = super::require_single(&selected, "items"));
424        assert!(err.to_string().contains("expected exactly one selection"));
425    }
426
427    #[test]
428    fn test_minimal_multi_select_line_serialization_sanitizes_fields() {
429        let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
430        let display = normalize("\u{1b}[31mvisible\tvalue\nnext\u{1b}[0m");
431        let hidden_search = normalize("hidden\rvalue");
432        assert2::assert!(let Ok(mut display_items) = super::build_ansi_display_items(std::slice::from_ref(&display)));
433        let display_item = display_items.swap_remove(0);
434        let match_text = format!("{} {hidden_search}", display_item.text());
435
436        let item = super::IndexedSkimItem {
437            output: "3".to_owned(),
438            display_item,
439            visible_text: "visible value next".to_owned(),
440            search_corpus: match_text,
441        };
442
443        pretty_assertions::assert_eq!(item.output(), "3");
444        pretty_assertions::assert_eq!(item.text(), "visible value next");
445        pretty_assertions::assert_eq!(
446            item.display(DisplayContext::default())
447                .spans
448                .first()
449                .map(|span| span.content.as_ref()),
450            Some("visible value next")
451        );
452    }
453
454    #[test]
455    fn test_clip_match_range_char_indices_hides_hidden_only_match() {
456        let item = super::IndexedSkimItem {
457            output: "3".to_owned(),
458            display_item: Arc::new("visible value next".to_owned()),
459            visible_text: "visible value next".to_owned(),
460            search_corpus: "visible value next hidden value".to_owned(),
461        };
462
463        assert!(matches!(
464            super::clip_match_range(MatchRange::Chars(vec![20, 21]), &item),
465            MatchRange::Chars(indices) if indices.is_empty()
466        ));
467    }
468}