tuiscope/
data.rs

1use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
2use indexmap::IndexMap;
3use rayon::prelude::*;
4use std::{borrow::Cow, cmp::Ordering};
5use tui::widgets::ListState;
6
7/// Type for holding fuzzy match score with corresponding indices
8pub struct FuzzyScore {
9    /// fuzzy match score
10    pub score: i64,
11    /// fuzzy match indices (positions in the matched string)
12    pub indices: Vec<usize>,
13}
14
15impl Ord for FuzzyScore {
16    fn cmp(&self, other: &Self) -> Ordering {
17        // reverse so ascending order is highest score first!!!
18        other.score.cmp(&self.score)
19    }
20}
21
22impl PartialOrd for FuzzyScore {
23    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
24        Some(self.cmp(other))
25    }
26}
27
28impl PartialEq for FuzzyScore {
29    fn eq(&self, other: &Self) -> bool {
30        self.score == other.score
31    }
32}
33
34impl Eq for FuzzyScore {}
35
36/// Return type for `FuzzyFinder::selection`
37#[derive(Clone)]
38pub struct FuzzyListEntry<'a> {
39    /// value of entry
40    pub value: &'a str, // TODO not a &str?
41    /// fuzzy match score
42    pub score: i64,
43    /// fuzzy match indices (positions in `value`)
44    pub indices: Vec<usize>,
45}
46
47/// State for `FuzzyList<K>`.  Hold on to one of these and pass to `render_stateful_widget`
48///
49/// # Example
50///
51/// ```
52/// use tui::prelude::*;
53/// use tui::widgets::*;
54/// use tuiscope::{FuzzyFinder, FuzzyList};
55///
56/// fn ui(f: &mut Frame<'_>, state: &mut FuzzyFinder) {
57///     let chunks = Layout::default()
58///         .direction(Direction::Vertical)
59///         .constraints([Constraint::Min(1)].as_ref())
60///         .split(f.size());
61///
62///     let fuzzy_results = FuzzyList::default()
63///         .block(Block::default().borders(Borders::ALL).title("Options"))
64///         .matched_char_style(Style::default().fg(Color::Cyan))
65///         .selection_highlight_style(Style::default().add_modifier(Modifier::BOLD));
66///     f.render_stateful_widget(fuzzy_results, chunks[2], state);
67/// }
68/// ```
69#[derive(Default)]
70pub struct FuzzyFinder<'a> {
71    /// The current filter string.
72    filter: Cow<'a, str>,
73    /// IndexMap of FuzzyScore.
74    pub matches: IndexMap<Cow<'a, str>, Option<FuzzyScore>>,
75    /// State for the `FuzzyList` widget's selection.
76    pub state: ListState,
77}
78
79impl<'a> FuzzyFinder<'a> {
80    /// Clears the filter term.
81    ///
82    /// # Example
83    ///
84    /// ```
85    /// use tuiscope::FuzzyFinder;
86    ///
87    /// let mut ff = FuzzyFinder::default();
88    /// ff.set_filter("foo");
89    /// ff.clear_filter();
90    /// ```
91    pub fn clear_filter(&mut self) -> &mut Self {
92        self.filter = Cow::default();
93        self.update_matches(true);
94        self
95    }
96
97    /// Resets the selected line from filtered options to the 0th.
98    fn reset_selection(&mut self) -> &mut Self {
99        if self.matches.is_empty() {
100            self.state.select(None);
101        } else {
102            self.state.select(Some(0));
103        }
104        self
105    }
106
107    /// Select the next filtered entry.
108    ///
109    /// # Example
110    ///
111    /// ```
112    /// use tuiscope::FuzzyFinder;
113    ///
114    /// let mut ff = FuzzyFinder::default();
115    /// ff.select_next();
116    /// ```
117    pub fn select_next(&mut self) -> &mut Self {
118        if let Some(current) = self.state.selected() {
119            self.select(current + 1);
120        } else {
121            self.reset_selection();
122        }
123        self
124    }
125
126    /// Select the previous filtered entry.
127    ///
128    /// # Example
129    ///
130    /// ```
131    /// use tuiscope::FuzzyFinder;
132    ///
133    /// let mut ff = FuzzyFinder::default();
134    /// ff.select_next();
135    /// ff.select_next();
136    /// ff.select_prev();
137    /// ```
138    pub fn select_prev(&mut self) -> &mut Self {
139        if let Some(current) = self.state.selected() {
140            if current > 0 {
141                self.select(current - 1);
142            }
143        } else {
144            self.reset_selection();
145        }
146        self
147    }
148
149    fn select(&mut self, index: usize) -> &mut Self {
150        let len = self.matches.len();
151        if len < 1 {
152            return self.reset_selection();
153        }
154        self.state.select(Some(std::cmp::min(index, len - 1)));
155        self
156    }
157
158    /// Get the current selected entry.
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use tuiscope::FuzzyFinder;
164    ///
165    /// let mut ff = FuzzyFinder::default();
166    /// ff.select_next();
167    /// ff.select_next();
168    /// ff.select_prev();
169    /// let answer = ff.selection();
170    /// ```
171    pub fn selection(&self) -> Option<FuzzyListEntry> {
172        self.state.selected().and_then(|i| {
173            self.matches.get_index(i).and_then(|(value, score)| {
174                score
175                    .as_ref()
176                    .map(|FuzzyScore { score, indices }| FuzzyListEntry {
177                        value,
178                        indices: indices.clone(),
179                        score: *score,
180                    })
181            })
182        })
183    }
184
185    /// Updates the filter term.
186    ///
187    /// # Example
188    ///
189    /// ```
190    /// use tuiscope::FuzzyFinder;
191    ///
192    /// let mut ff = FuzzyFinder::default();
193    /// ff.set_filter("foo");
194    /// ```
195    pub fn set_filter<T: Into<Cow<'a, str>>>(&mut self, filter: T) -> &mut Self {
196        self.filter = filter.into();
197        self.update_matches(true);
198        self
199    }
200
201    /// Updates the set of options to search by adding from an iterator.
202    ///
203    /// # Example
204    ///
205    /// ```
206    /// use tuiscope::FuzzyFinder;
207    ///
208    /// let mut ff = FuzzyFinder::default();
209    /// ff.push_options(["abc", "bcd", "cde"]);
210    /// ```
211    pub fn push_options<T: 'a + IntoIterator<Item = R>, R: Into<Cow<'a, str>>>(
212        &mut self,
213        options: T,
214    ) -> &mut Self {
215        for option in options {
216            self._push_option(option);
217        }
218        self.update_matches(false);
219        self
220    }
221
222    /// Builder method which sets search options.
223    ///
224    /// # Example
225    ///
226    /// ```
227    /// use tuiscope::FuzzyFinder;
228    ///
229    /// let ff = FuzzyFinder::default().with_options(["one", "two", "three"]);
230    /// ```
231    pub fn with_options<T: 'a + IntoIterator<Item = R>, R: Into<Cow<'a, str>>>(
232        mut self,
233        options: T,
234    ) -> Self {
235        self.set_options(options);
236        self
237    }
238
239    /// Sets search options.
240    ///
241    /// # Example
242    ///
243    /// ```
244    /// use tuiscope::FuzzyFinder;
245    ///
246    /// let mut ff = FuzzyFinder::default();
247    /// ff.set_options(["one", "two", "three"]);
248    /// ```
249    pub fn set_options<T: 'a + IntoIterator<Item = R>, R: Into<Cow<'a, str>>>(
250        &mut self,
251        options: T,
252    ) -> &mut Self {
253        // TODO be more efficient, keep any existing scores for overlapping keys.
254        // Maybe leverage `remove_options` when an efficient  version of that has
255        // been made.
256        self.matches.clear();
257        self.push_options(options);
258        self
259    }
260
261    /// Add an option to search.
262    ///
263    /// # Example
264    ///
265    /// ```
266    /// use tuiscope::FuzzyFinder;
267    ///
268    /// let mut ff = FuzzyFinder::default();
269    /// ff.push_option("hello");
270    /// ```
271    pub fn push_option<R: Into<Cow<'a, str>>>(&mut self, option: R) {
272        self._push_option(option);
273        self.update_matches(false);
274    }
275
276    /// Adds an option to search without updating.
277    fn _push_option<R: Into<Cow<'a, str>>>(&mut self, option: R) {
278        // keep existing score if entry exists.
279        self.matches.entry(option.into()).or_insert(None);
280    }
281
282    /// Removes an option.
283    ///
284    /// # Example
285    ///
286    /// ```
287    /// use tuiscope::FuzzyFinder;
288    ///
289    /// let mut ff = FuzzyFinder::default();
290    /// ff.push_options(["hello", "friend"]);
291    /// ff.remove_option("hello");
292    /// ```
293    pub fn remove_option<R: AsRef<str>>(&mut self, key: R) {
294        self.matches.shift_remove(key.as_ref());
295    }
296
297    /// Removes multiple options.
298    ///
299    /// # Example
300    ///
301    /// ```
302    /// use tuiscope::FuzzyFinder;
303    ///
304    /// let mut ff = FuzzyFinder::default();
305    /// ff.push_options(["hello", "my", "old", "friend"]);
306    /// ff.remove_options(["my", "old"]);
307    /// ```
308    pub fn remove_options<T: 'a + IntoIterator<Item = R>, R: AsRef<str>>(&mut self, keys: T) {
309        // TODO something smarter, this will O(n) shift all entries in `self.matches`
310        // for each key.  Will in certain cases be better to just `self.matches.remove`
311        // followed by a sort.
312        for key in keys {
313            self.remove_option(key);
314        }
315    }
316
317    /// Computes new scores for all options if `new_filter_term` is true.
318    /// Otherwise competes scores for all options who haven't had a calculation
319    /// yet against the current filter.
320    fn update_matches(&mut self, new_filter_term: bool) {
321        let matcher = SkimMatcherV2::default();
322
323        // TODO None matches were inserted last, so we should be able to iterate
324        // from the end and stop early.  But I couldn't quite find the right
325        // early-stopping option for an IndexedParallesIterator
326        // iter = iter.rev().take_any_while... race behavior is not ideal
327        self.matches
328            .par_iter_mut()
329            .filter(|(_, score)| new_filter_term || score.is_none())
330            .for_each(|(value, score)| {
331                *score = matcher
332                    .fuzzy_indices(value, &self.filter)
333                    .map(|(score, indices)| FuzzyScore { score, indices });
334            });
335
336        self.matches.par_sort_unstable_by(|_, v1, _, v2| match v1 {
337            Some(v1) => match v2 {
338                Some(v2) => v1.cmp(v2),
339                None => Ordering::Less,
340            },
341            None => Ordering::Greater,
342        });
343
344        // TODO only if some change
345        self.reset_selection();
346    }
347}
348
349#[cfg(test)]
350mod test {
351    use super::*;
352
353    #[test]
354    fn remove_option() {
355        let mut ff = FuzzyFinder::default();
356        ff.push_options(["hello", "friend"]);
357        assert!(ff.matches.contains_key("hello"));
358        ff.remove_option("hello");
359        assert!(!ff.matches.contains_key("hello"));
360    }
361
362    #[test]
363    fn remove_options() {
364        let mut ff = FuzzyFinder::default();
365        ff.push_options(["hello", "my", "old", "friend"]);
366        ff.remove_options(["my", "old"]);
367        assert!(ff.matches.contains_key("hello"));
368        assert!(ff.matches.contains_key("friend"));
369        assert!(!ff.matches.contains_key("my"));
370        assert!(!ff.matches.contains_key("old"));
371    }
372}