zellij_nucleo/
lib.rs

1//! This crate provides a fuzzy finder widget based on
2//! [nucleo-matcher](https://crates.io/crates/nucleo-matcher) for use in
3//! [zellij plugins](https://zellij.dev/documentation/plugins). It can be used by
4//! your own plugins to allow easy searching through a list of options, and
5//! automatically handles the picker UI as needed.
6//!
7//! ## Usage
8//!
9//! A basic plugin that uses the `zellij-nucleo` crate to switch tabs can be
10//! structured like this:
11//!
12//! ```rust
13//! use zellij_tile::prelude::*;
14//!
15//! #[derive(Default)]
16//! struct State {
17//!     picker: zellij_nucleo::Picker<u32>,
18//! }
19//!
20//! register_plugin!(State);
21//!
22//! impl ZellijPlugin for State {
23//!     fn load(
24//!         &mut self,
25//!         configuration: std::collections::BTreeMap<String, String>,
26//!     ) {
27//!         request_permission(&[
28//!             PermissionType::ReadApplicationState,
29//!             PermissionType::ChangeApplicationState,
30//!         ]);
31//!
32//!         subscribe(&[EventType::TabUpdate]);
33//!         self.picker.load(&configuration);
34//!     }
35//!
36//!     fn update(&mut self, event: Event) -> bool {
37//!         match self.picker.update(&event) {
38//!             Some(zellij_nucleo::Response::Select(entry)) => {
39//!                 go_to_tab(entry.data);
40//!                 close_self();
41//!             }
42//!             Some(zellij_nucleo::Response::Cancel) => {
43//!                 close_self();
44//!             }
45//!             None => {}
46//!         }
47//!
48//!         if let Event::TabUpdate(tabs) = event {
49//!             self.picker.clear();
50//!             self.picker.extend(tabs.iter().map(|tab| zellij_nucleo::Entry {
51//!                 data: u32::try_from(tab.position).unwrap(),
52//!                 string: format!("{}: {}", tab.position + 1, tab.name),
53//!             }));
54//!         }
55//!
56//!         self.picker.needs_redraw()
57//!     }
58//!
59//!     fn render(&mut self, rows: usize, cols: usize) {
60//!         self.picker.render(rows, cols);
61//!     }
62//! }
63//! ```
64
65use zellij_tile::prelude::*;
66
67use std::fmt::Write as _;
68
69use owo_colors::OwoColorize as _;
70use unicode_width::UnicodeWidthChar as _;
71
72const PICKER_EVENTS: &[EventType] = &[EventType::Key];
73
74/// An entry in the picker.
75///
76/// The type parameter corresponds to the type of the additional data
77/// associated with each entry.
78#[derive(Debug)]
79pub struct Entry<T> {
80    /// String that will be displayed in the picker window, and filtered when
81    /// searching.
82    pub string: String,
83    /// Extra data associated with the picker entry, which can be retrieved
84    /// when an entry is selected.
85    pub data: T,
86}
87
88impl<T> AsRef<str> for Entry<T> {
89    fn as_ref(&self) -> &str {
90        &self.string
91    }
92}
93
94/// Possible results from the picker.
95#[derive(Debug)]
96pub enum Response {
97    /// The user selected a specific entry.
98    Select(usize),
99    /// The user closed the picker without selecting an entry.
100    Cancel,
101}
102
103#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
104enum InputMode {
105    #[default]
106    Normal,
107    Search,
108}
109
110/// State of the picker itself.
111#[derive(Default)]
112pub struct Picker<T> {
113    query: String,
114    all_entries: Vec<Entry<T>>,
115    search_results: Vec<SearchResult>,
116    selected: usize,
117    input_mode: InputMode,
118    needs_redraw: bool,
119
120    pattern: nucleo_matcher::pattern::Pattern,
121    matcher: nucleo_matcher::Matcher,
122    case_matching: nucleo_matcher::pattern::CaseMatching,
123}
124
125impl<T> Picker<T> {
126    /// This function must be called during your plugin's
127    /// [`load`](zellij_tile::ZellijPlugin::load) function.
128    pub fn load(
129        &mut self,
130        configuration: &std::collections::BTreeMap<String, String>,
131    ) {
132        subscribe(PICKER_EVENTS);
133
134        match configuration
135            .get("nucleo_case_matching")
136            .map(|s| s.as_ref())
137        {
138            Some("respect") => {
139                self.case_matching =
140                    nucleo_matcher::pattern::CaseMatching::Respect
141            }
142            Some("ignore") => {
143                self.case_matching =
144                    nucleo_matcher::pattern::CaseMatching::Ignore
145            }
146            Some("smart") => {
147                self.case_matching =
148                    nucleo_matcher::pattern::CaseMatching::Smart
149            }
150            Some(s) => {
151                panic!("unrecognized value {s} for option 'nucleo_case_matching': expected 'respect', 'ignore', 'smart'");
152            }
153            None => {}
154        }
155
156        match configuration.get("nucleo_match_paths").map(|s| s.as_ref()) {
157            Some("true") => {
158                self.set_match_paths();
159            }
160            Some("false") => {
161                self.clear_match_paths();
162            }
163            Some(s) => {
164                panic!("unrecognized value {s} for option 'nucleo_match_paths': expected 'true', 'false'");
165            }
166            None => {}
167        }
168
169        match configuration
170            .get("nucleo_start_in_search_mode")
171            .map(|s| s.as_ref())
172        {
173            Some("true") => {
174                self.enter_search_mode();
175            }
176            Some("false") => {
177                self.enter_normal_mode();
178            }
179            Some(s) => {
180                panic!("unrecognized value {s} for option 'nucleo_start_in_search_mode': expected 'true', 'false'");
181            }
182            None => {}
183        }
184    }
185
186    /// This function must be called during your plugin's
187    /// [`update`](zellij_tile::ZellijPlugin::update) function. If an entry
188    /// was selected or the picker was closed, this function will return a
189    /// [`Response`]. This function will update the picker's internal state
190    /// of whether it needs to redraw the picker, so your plugin's
191    /// [`update`](zellij_tile::ZellijPlugin::update) function should return
192    /// true if [`needs_redraw`](Self::needs_redraw) returns true.
193    pub fn update(&mut self, event: &Event) -> Option<Response> {
194        match event {
195            Event::Key(key) => self.handle_key(key),
196            _ => None,
197        }
198    }
199
200    /// This function must be called during your plugin's
201    /// [`render`](zellij_tile::ZellijPlugin::render) function.
202    pub fn render(&mut self, rows: usize, cols: usize) {
203        if rows == 0 {
204            return;
205        }
206
207        let visible_entry_count = rows - 1;
208        let visible_entries_start =
209            (self.selected / visible_entry_count) * visible_entry_count;
210        let visible_selected = self.selected % visible_entry_count;
211
212        print!("  ");
213        if self.input_mode == InputMode::Normal && self.query.is_empty() {
214            print!(
215                "{}",
216                "(press / to search)".fg::<owo_colors::colors::BrightBlack>()
217            );
218        } else {
219            print!("{}", self.query);
220            if self.input_mode == InputMode::Search {
221                print!("{}", " ".bg::<owo_colors::colors::Green>());
222            }
223        }
224        println!();
225
226        let lines: Vec<_> = self
227            .search_results
228            .iter()
229            .skip(visible_entries_start)
230            .take(visible_entry_count)
231            .enumerate()
232            .map(|(i, search_result)| {
233                let mut line = String::new();
234
235                if i == visible_selected {
236                    write!(
237                        &mut line,
238                        "{} ",
239                        ">".fg::<owo_colors::colors::Yellow>()
240                    )
241                    .unwrap();
242                } else {
243                    write!(&mut line, "  ").unwrap();
244                }
245
246                let mut current_col = 2;
247                for (char_idx, c) in self.all_entries[search_result.entry]
248                    .string
249                    .chars()
250                    .enumerate()
251                {
252                    let width = c.width().unwrap_or(0);
253                    if current_col + width > cols - 6 {
254                        write!(
255                            &mut line,
256                            "{}",
257                            " [...]".fg::<owo_colors::colors::BrightBlack>()
258                        )
259                        .unwrap();
260                        break;
261                    }
262
263                    if search_result
264                        .indices
265                        .contains(&u32::try_from(char_idx).unwrap())
266                    {
267                        write!(
268                            &mut line,
269                            "{}",
270                            c.fg::<owo_colors::colors::Cyan>()
271                        )
272                        .unwrap();
273                    } else if i == visible_selected {
274                        write!(
275                            &mut line,
276                            "{}",
277                            c.fg::<owo_colors::colors::Yellow>()
278                        )
279                        .unwrap();
280                    } else {
281                        write!(&mut line, "{}", c).unwrap();
282                    }
283
284                    current_col += width;
285                }
286                line
287            })
288            .collect();
289
290        print!("{}", lines.join("\n"));
291
292        self.needs_redraw = false;
293    }
294
295    /// Returns true if the picker needs to be redrawn. Your plugin's
296    /// [`update`](zellij_tile::ZellijPlugin::update) function should return
297    /// true if this function returns true.
298    pub fn needs_redraw(&self) -> bool {
299        self.needs_redraw
300    }
301
302    /// Returns the current list of entries in the picker.
303    pub fn entries(&self) -> &[Entry<T>] {
304        &self.all_entries
305    }
306
307    /// Forces a specific entry in the list of entries to be selected.
308    pub fn select(&mut self, idx: usize) {
309        self.selected = idx;
310        self.needs_redraw = true;
311    }
312
313    /// Removes all entries in the list.
314    pub fn clear(&mut self) {
315        self.all_entries.clear();
316        self.search();
317    }
318
319    /// Adds new entries to the list.
320    pub fn extend(&mut self, iter: impl IntoIterator<Item = Entry<T>>) {
321        let prev_selected =
322            self.search_results.get(self.selected).map(|search_result| {
323                self.all_entries[search_result.entry].string.clone()
324            });
325
326        self.all_entries.extend(iter);
327        self.search();
328
329        if let Some(prev_selected) = prev_selected {
330            self.selected = self
331                .search_results
332                .iter()
333                .enumerate()
334                .find_map(|(idx, search_result)| {
335                    (self.all_entries[search_result.entry].string
336                        == prev_selected)
337                        .then_some(idx)
338                })
339                .unwrap_or(0);
340        } else {
341            self.selected = 0;
342        }
343    }
344
345    /// Request that the fuzzy matcher always respect case when matching.
346    pub fn use_case_matching_respect(&mut self) {
347        self.case_matching = nucleo_matcher::pattern::CaseMatching::Respect;
348    }
349
350    /// Request that the fuzzy matcher always ignore case when matching.
351    pub fn use_case_matching_ignore(&mut self) {
352        self.case_matching = nucleo_matcher::pattern::CaseMatching::Ignore;
353    }
354
355    /// Request that the fuzzy matcher respect case when matching if the
356    /// query contains any uppercase characters, but ignore case otherwise.
357    /// This is the default.
358    pub fn use_case_matching_smart(&mut self) {
359        self.case_matching = nucleo_matcher::pattern::CaseMatching::Smart;
360    }
361
362    /// Puts the picker into search mode (equivalent to pressing `/` when in
363    /// normal mode).
364    pub fn enter_search_mode(&mut self) {
365        self.input_mode = InputMode::Search;
366    }
367
368    /// Puts the picker into normal mode (equivalent to pressing Escape when
369    /// in search mode). This is the default.
370    pub fn enter_normal_mode(&mut self) {
371        self.input_mode = InputMode::Normal;
372    }
373
374    /// Configures the fuzzy matcher to adjust matching bonuses appropriate
375    /// for matching paths.
376    pub fn set_match_paths(&mut self) {
377        self.matcher.config.set_match_paths();
378    }
379
380    /// Configures the fuzzy matcher to adjust matching bonuses appropriate
381    /// for matching arbitrary strings. This is the default.
382    pub fn clear_match_paths(&mut self) {
383        self.matcher.config = nucleo_matcher::Config::DEFAULT;
384    }
385
386    fn search(&mut self) {
387        self.pattern.reparse(
388            &self.query,
389            self.case_matching,
390            nucleo_matcher::pattern::Normalization::Smart,
391        );
392        let mut haystack = vec![];
393        self.search_results = self
394            .all_entries
395            .iter()
396            .enumerate()
397            .filter_map(|(i, entry)| {
398                let haystack = nucleo_matcher::Utf32Str::new(
399                    &entry.string,
400                    &mut haystack,
401                );
402                let mut indices = vec![];
403                self.pattern
404                    .indices(haystack, &mut self.matcher, &mut indices)
405                    .map(|score| SearchResult {
406                        entry: i,
407                        score,
408                        indices,
409                    })
410            })
411            .collect();
412        self.search_results.sort_by_key(|search_result| {
413            SearchResultWithString {
414                score: search_result.score,
415                first_index: search_result.indices.first().copied(),
416                string: &self.all_entries[search_result.entry].string,
417            }
418        });
419
420        self.needs_redraw = true;
421    }
422
423    fn handle_key(&mut self, key: &KeyWithModifier) -> Option<Response> {
424        self.handle_global_key(key)
425            .or_else(|| match self.input_mode {
426                InputMode::Normal => self.handle_normal_key(key),
427                InputMode::Search => self.handle_search_key(key),
428            })
429    }
430
431    fn handle_normal_key(
432        &mut self,
433        key: &KeyWithModifier,
434    ) -> Option<Response> {
435        match key.bare_key {
436            BareKey::Char('j') if key.has_no_modifiers() => {
437                self.down();
438            }
439            BareKey::Char('k') if key.has_no_modifiers() => {
440                self.up();
441            }
442            BareKey::Char(c @ '1'..='8') if key.has_no_modifiers() => {
443                let position =
444                    usize::try_from(c.to_digit(10).unwrap() - 1).unwrap();
445                return self.search_results.get(position).map(
446                    |search_result| Response::Select(search_result.entry),
447                );
448            }
449            BareKey::Char('9') if key.has_no_modifiers() => {
450                return self.search_results.last().map(|search_result| {
451                    Response::Select(search_result.entry)
452                })
453            }
454            BareKey::Char('/') if key.has_no_modifiers() => {
455                self.input_mode = InputMode::Search;
456                self.needs_redraw = true;
457            }
458            _ => {}
459        }
460
461        None
462    }
463
464    fn handle_search_key(
465        &mut self,
466        key: &KeyWithModifier,
467    ) -> Option<Response> {
468        match key.bare_key {
469            BareKey::Char(c) if key.has_no_modifiers() => {
470                self.query.push(c);
471                self.search();
472                self.selected = 0;
473            }
474            BareKey::Char('u') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
475                self.query.clear();
476                self.search();
477                self.selected = 0;
478            }
479            BareKey::Backspace if key.has_no_modifiers() => {
480                self.query.pop();
481                self.search();
482                self.selected = 0;
483            }
484            _ => {}
485        }
486
487        None
488    }
489
490    fn handle_global_key(
491        &mut self,
492        key: &KeyWithModifier,
493    ) -> Option<Response> {
494        match key.bare_key {
495            BareKey::Tab if key.has_no_modifiers() => {
496                self.down();
497            }
498            BareKey::Down if key.has_no_modifiers() => {
499                self.down();
500            }
501            BareKey::Tab if key.has_modifiers(&[KeyModifier::Shift]) => {
502                self.up();
503            }
504            BareKey::Up if key.has_no_modifiers() => {
505                self.up();
506            }
507            BareKey::Esc if key.has_no_modifiers() => {
508                self.input_mode = InputMode::Normal;
509                self.needs_redraw = true;
510            }
511            BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
512                return Some(Response::Cancel);
513            }
514            BareKey::Enter if key.has_no_modifiers() => {
515                return Some(Response::Select(
516                    self.search_results[self.selected].entry,
517                ));
518            }
519            _ => {}
520        }
521
522        None
523    }
524
525    fn down(&mut self) {
526        if self.search_results.is_empty() {
527            return;
528        }
529        self.selected = (self.search_results.len() + self.selected + 1)
530            % self.search_results.len();
531        self.needs_redraw = true;
532    }
533
534    fn up(&mut self) {
535        if self.search_results.is_empty() {
536            return;
537        }
538        self.selected = (self.search_results.len() + self.selected - 1)
539            % self.search_results.len();
540        self.needs_redraw = true;
541    }
542}
543
544#[derive(Debug)]
545struct SearchResult {
546    entry: usize,
547    score: u32,
548    indices: Vec<u32>,
549}
550
551#[derive(Debug)]
552struct SearchResultWithString<'a> {
553    score: u32,
554    first_index: Option<u32>,
555    string: &'a str,
556}
557
558impl Ord for SearchResultWithString<'_> {
559    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
560        self.score
561            .cmp(&other.score)
562            .reverse()
563            .then_with(|| self.first_index.cmp(&other.first_index))
564            .then_with(|| self.string.cmp(other.string))
565    }
566}
567
568impl PartialOrd for SearchResultWithString<'_> {
569    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
570        Some(self.cmp(other))
571    }
572}
573
574impl Eq for SearchResultWithString<'_> {}
575
576impl PartialEq for SearchResultWithString<'_> {
577    fn eq(&self, other: &Self) -> bool {
578        self.cmp(other) == std::cmp::Ordering::Equal
579    }
580}