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
24pub 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
43pub 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
141pub 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
171pub 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
183pub 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
202pub 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
216pub 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
347fn 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
358fn 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
370fn 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
383fn 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
391fn 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}