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}