Skip to main content

ytil_tui/
interactive.rs

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