aboutsummaryrefslogtreecommitdiff
path: root/helix-term/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term/src/ui')
-rw-r--r--helix-term/src/ui/fuzzy_match.rs239
-rw-r--r--helix-term/src/ui/fuzzy_match/test.rs47
-rw-r--r--helix-term/src/ui/menu.rs51
-rw-r--r--helix-term/src/ui/mod.rs184
-rw-r--r--helix-term/src/ui/picker.rs545
5 files changed, 335 insertions, 731 deletions
diff --git a/helix-term/src/ui/fuzzy_match.rs b/helix-term/src/ui/fuzzy_match.rs
deleted file mode 100644
index 22dc3a7f..00000000
--- a/helix-term/src/ui/fuzzy_match.rs
+++ /dev/null
@@ -1,239 +0,0 @@
-use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
-use fuzzy_matcher::FuzzyMatcher;
-
-#[cfg(test)]
-mod test;
-
-struct QueryAtom {
- kind: QueryAtomKind,
- atom: String,
- ignore_case: bool,
- inverse: bool,
-}
-impl QueryAtom {
- fn new(atom: &str) -> Option<QueryAtom> {
- let mut atom = atom.to_string();
- let inverse = atom.starts_with('!');
- if inverse {
- atom.remove(0);
- }
-
- let mut kind = match atom.chars().next() {
- Some('^') => QueryAtomKind::Prefix,
- Some('\'') => QueryAtomKind::Substring,
- _ if inverse => QueryAtomKind::Substring,
- _ => QueryAtomKind::Fuzzy,
- };
-
- if atom.starts_with(['^', '\'']) {
- atom.remove(0);
- }
-
- if atom.is_empty() {
- return None;
- }
-
- if atom.ends_with('$') && !atom.ends_with("\\$") {
- atom.pop();
- kind = if kind == QueryAtomKind::Prefix {
- QueryAtomKind::Exact
- } else {
- QueryAtomKind::Postfix
- }
- }
-
- Some(QueryAtom {
- kind,
- atom: atom.replace('\\', ""),
- // not ideal but fuzzy_matches only knows ascii uppercase so more consistent
- // to behave the same
- ignore_case: kind != QueryAtomKind::Fuzzy
- && atom.chars().all(|c| c.is_ascii_lowercase()),
- inverse,
- })
- }
-
- fn indices(&self, matcher: &Matcher, item: &str, indices: &mut Vec<usize>) -> bool {
- // for inverse there are no indices to return
- // just return whether we matched
- if self.inverse {
- return self.matches(matcher, item);
- }
- let buf;
- let item = if self.ignore_case {
- buf = item.to_ascii_lowercase();
- &buf
- } else {
- item
- };
- let off = match self.kind {
- QueryAtomKind::Fuzzy => {
- if let Some((_, fuzzy_indices)) = matcher.fuzzy_indices(item, &self.atom) {
- indices.extend_from_slice(&fuzzy_indices);
- return true;
- } else {
- return false;
- }
- }
- QueryAtomKind::Substring => {
- if let Some(off) = item.find(&self.atom) {
- off
- } else {
- return false;
- }
- }
- QueryAtomKind::Prefix if item.starts_with(&self.atom) => 0,
- QueryAtomKind::Postfix if item.ends_with(&self.atom) => item.len() - self.atom.len(),
- QueryAtomKind::Exact if item == self.atom => 0,
- _ => return false,
- };
-
- indices.extend(off..(off + self.atom.len()));
- true
- }
-
- fn matches(&self, matcher: &Matcher, item: &str) -> bool {
- let buf;
- let item = if self.ignore_case {
- buf = item.to_ascii_lowercase();
- &buf
- } else {
- item
- };
- let mut res = match self.kind {
- QueryAtomKind::Fuzzy => matcher.fuzzy_match(item, &self.atom).is_some(),
- QueryAtomKind::Substring => item.contains(&self.atom),
- QueryAtomKind::Prefix => item.starts_with(&self.atom),
- QueryAtomKind::Postfix => item.ends_with(&self.atom),
- QueryAtomKind::Exact => item == self.atom,
- };
- if self.inverse {
- res = !res;
- }
- res
- }
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-enum QueryAtomKind {
- /// Item is a fuzzy match of this behaviour
- ///
- /// Usage: `foo`
- Fuzzy,
- /// Item contains query atom as a continuous substring
- ///
- /// Usage `'foo`
- Substring,
- /// Item starts with query atom
- ///
- /// Usage: `^foo`
- Prefix,
- /// Item ends with query atom
- ///
- /// Usage: `foo$`
- Postfix,
- /// Item is equal to query atom
- ///
- /// Usage `^foo$`
- Exact,
-}
-
-#[derive(Default)]
-pub struct FuzzyQuery {
- first_fuzzy_atom: Option<String>,
- query_atoms: Vec<QueryAtom>,
-}
-
-fn query_atoms(query: &str) -> impl Iterator<Item = &str> + '_ {
- let mut saw_backslash = false;
- query.split(move |c| {
- saw_backslash = match c {
- ' ' if !saw_backslash => return true,
- '\\' => true,
- _ => false,
- };
- false
- })
-}
-
-impl FuzzyQuery {
- pub fn refine(&self, query: &str, old_query: &str) -> (FuzzyQuery, bool) {
- // TODO: we could be a lot smarter about this
- let new_query = Self::new(query);
- let mut is_refinement = query.starts_with(old_query);
-
- // if the last atom is an inverse atom adding more text to it
- // will actually increase the number of matches and we can not refine
- // the matches.
- if is_refinement && !self.query_atoms.is_empty() {
- let last_idx = self.query_atoms.len() - 1;
- if self.query_atoms[last_idx].inverse
- && self.query_atoms[last_idx].atom != new_query.query_atoms[last_idx].atom
- {
- is_refinement = false;
- }
- }
-
- (new_query, is_refinement)
- }
-
- pub fn new(query: &str) -> FuzzyQuery {
- let mut first_fuzzy_query = None;
- let query_atoms = query_atoms(query)
- .filter_map(|atom| {
- let atom = QueryAtom::new(atom)?;
- if atom.kind == QueryAtomKind::Fuzzy && first_fuzzy_query.is_none() {
- first_fuzzy_query = Some(atom.atom);
- None
- } else {
- Some(atom)
- }
- })
- .collect();
- FuzzyQuery {
- first_fuzzy_atom: first_fuzzy_query,
- query_atoms,
- }
- }
-
- pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
- // use the rank of the first fuzzzy query for the rank, because merging ranks is not really possible
- // this behaviour matches fzf and skim
- let score = self
- .first_fuzzy_atom
- .as_ref()
- .map_or(Some(0), |atom| matcher.fuzzy_match(item, atom))?;
- if self
- .query_atoms
- .iter()
- .any(|atom| !atom.matches(matcher, item))
- {
- return None;
- }
- Some(score)
- }
-
- pub fn fuzzy_indices(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
- let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else(
- || Some((0, Vec::new())),
- |atom| matcher.fuzzy_indices(item, atom),
- )?;
-
- // fast path for the common case of just a single atom
- if self.query_atoms.is_empty() {
- return Some((score, indices));
- }
-
- for atom in &self.query_atoms {
- if !atom.indices(matcher, item, &mut indices) {
- return None;
- }
- }
-
- // deadup and remove duplicate matches
- indices.sort_unstable();
- indices.dedup();
-
- Some((score, indices))
- }
-}
diff --git a/helix-term/src/ui/fuzzy_match/test.rs b/helix-term/src/ui/fuzzy_match/test.rs
deleted file mode 100644
index 5df79eeb..00000000
--- a/helix-term/src/ui/fuzzy_match/test.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-use crate::ui::fuzzy_match::FuzzyQuery;
-use crate::ui::fuzzy_match::Matcher;
-
-fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec<String> {
- let query = FuzzyQuery::new(query);
- let matcher = Matcher::default();
- items
- .iter()
- .filter_map(|item| {
- let (_, indices) = query.fuzzy_indices(item, &matcher)?;
- let matched_string = indices
- .iter()
- .map(|&pos| item.chars().nth(pos).unwrap())
- .collect();
- Some(matched_string)
- })
- .collect()
-}
-
-#[test]
-fn match_single_value() {
- let matches = run_test("foo", &["foobar", "foo", "bar"]);
- assert_eq!(matches, &["foo", "foo"])
-}
-
-#[test]
-fn match_multiple_values() {
- let matches = run_test(
- "foo bar",
- &["foo bar", "foo bar", "bar foo", "bar", "foo"],
- );
- assert_eq!(matches, &["foobar", "foobar", "barfoo"])
-}
-
-#[test]
-fn space_escape() {
- let matches = run_test(r"foo\ bar", &["bar foo", "foo bar", "foobar"]);
- assert_eq!(matches, &["foo bar"])
-}
-
-#[test]
-fn trim() {
- let matches = run_test(r" foo bar ", &["bar foo", "foo bar", "foobar"]);
- assert_eq!(matches, &["barfoo", "foobar", "foobar"]);
- let matches = run_test(r" foo bar\ ", &["bar foo", "foo bar", "foobar"]);
- assert_eq!(matches, &["bar foo"])
-}
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index c73e7bed..8eeb41ee 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -1,22 +1,22 @@
-use std::{borrow::Cow, path::PathBuf};
+use std::{borrow::Cow, cmp::Reverse, path::PathBuf};
use crate::{
compositor::{Callback, Component, Compositor, Context, Event, EventResult},
ctrl, key, shift,
};
+use helix_core::fuzzy::MATCHER;
+use nucleo::pattern::{AtomKind, CaseMatching, Pattern};
+use nucleo::{Config, Utf32Str};
use tui::{buffer::Buffer as Surface, widgets::Table};
pub use tui::widgets::{Cell, Row};
-use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
-use fuzzy_matcher::FuzzyMatcher;
-
use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
use tui::layout::Constraint;
-pub trait Item {
+pub trait Item: Sync + Send + 'static {
/// Additional editor state that is used for label calculation.
- type Data;
+ type Data: Sync + Send + 'static;
fn format(&self, data: &Self::Data) -> Row;
@@ -51,9 +51,8 @@ pub struct Menu<T: Item> {
cursor: Option<usize>,
- matcher: Box<Matcher>,
/// (index, score)
- matches: Vec<(usize, i64)>,
+ matches: Vec<(u32, u32)>,
widths: Vec<Constraint>,
@@ -75,11 +74,10 @@ impl<T: Item> Menu<T> {
editor_data: <T as Item>::Data,
callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
) -> Self {
- let matches = (0..options.len()).map(|i| (i, 0)).collect();
+ let matches = (0..options.len() as u32).map(|i| (i, 0)).collect();
Self {
options,
editor_data,
- matcher: Box::new(Matcher::default().ignore_case()),
matches,
cursor: None,
widths: Vec::new(),
@@ -94,20 +92,19 @@ impl<T: Item> Menu<T> {
pub fn score(&mut self, pattern: &str) {
// reuse the matches allocation
self.matches.clear();
- self.matches.extend(
- self.options
- .iter()
- .enumerate()
- .filter_map(|(index, option)| {
- let text = option.filter_text(&self.editor_data);
- // TODO: using fuzzy_indices could give us the char idx for match highlighting
- self.matcher
- .fuzzy_match(&text, pattern)
- .map(|score| (index, score))
- }),
- );
- // Order of equal elements needs to be preserved as LSP preselected items come in order of high to low priority
- self.matches.sort_by_key(|(_, score)| -score);
+ let mut matcher = MATCHER.lock();
+ matcher.config = Config::DEFAULT;
+ let pattern = Pattern::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy);
+ let mut buf = Vec::new();
+ let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
+ let text = option.filter_text(&self.editor_data);
+ pattern
+ .score(Utf32Str::new(&text, &mut buf), &mut matcher)
+ .map(|score| (i as u32, score))
+ });
+ self.matches.extend(matches);
+ self.matches
+ .sort_unstable_by_key(|&(i, score)| (Reverse(score), i));
// reset cursor position
self.cursor = None;
@@ -201,7 +198,7 @@ impl<T: Item> Menu<T> {
self.cursor.and_then(|cursor| {
self.matches
.get(cursor)
- .map(|(index, _score)| &self.options[*index])
+ .map(|(index, _score)| &self.options[*index as usize])
})
}
@@ -209,7 +206,7 @@ impl<T: Item> Menu<T> {
self.cursor.and_then(|cursor| {
self.matches
.get(cursor)
- .map(|(index, _score)| &mut self.options[*index])
+ .map(|(index, _score)| &mut self.options[*index as usize])
})
}
@@ -332,7 +329,7 @@ impl<T: Item + 'static> Component for Menu<T> {
.iter()
.map(|(index, _score)| {
// (index, self.options.get(*index).unwrap()) // get_unchecked
- &self.options[*index] // get_unchecked
+ &self.options[*index as usize] // get_unchecked
})
.collect();
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 2d15fb32..3ca3d24d 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -1,7 +1,6 @@
mod completion;
mod document;
pub(crate) mod editor;
-mod fuzzy_match;
mod info;
pub mod lsp;
mod markdown;
@@ -64,7 +63,7 @@ pub fn regex_prompt(
prompt: std::borrow::Cow<'static, str>,
history_register: Option<char>,
completion_fn: impl FnMut(&Editor, &str) -> Vec<prompt::Completion> + 'static,
- fun: impl Fn(&mut Editor, Regex, PromptEvent) + 'static,
+ fun: impl Fn(&mut crate::compositor::Context, Regex, PromptEvent) + 'static,
) {
let (view, doc) = current!(cx.editor);
let doc_id = view.doc;
@@ -111,7 +110,7 @@ pub fn regex_prompt(
view.jumps.push((doc_id, snapshot.clone()));
}
- fun(cx.editor, regex, event);
+ fun(cx, regex, event);
let (view, doc) = current!(cx.editor);
view.ensure_cursor_in_view(doc, config.scrolloff);
@@ -174,6 +173,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
.git_ignore(config.file_picker.git_ignore)
.git_global(config.file_picker.git_global)
.git_exclude(config.file_picker.git_exclude)
+ .sort_by_file_name(|name1, name2| name1.cmp(name2))
.max_depth(config.file_picker.max_depth)
.filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks));
@@ -190,32 +190,16 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
.build()
.expect("failed to build excluded_types");
walk_builder.types(excluded_types);
-
- // We want files along with their modification date for sorting
let files = walk_builder.build().filter_map(|entry| {
let entry = entry.ok()?;
- // This is faster than entry.path().is_dir() since it uses cached fs::Metadata fetched by ignore/walkdir
- if entry.file_type()?.is_file() {
- Some(entry.into_path())
- } else {
- None
+ if !entry.file_type()?.is_file() {
+ return None;
}
+ Some(entry.into_path())
});
-
- // Cap the number of files if we aren't in a git project, preventing
- // hangs when using the picker in your home directory
- let mut files: Vec<PathBuf> = if root.join(".git").exists() {
- files.collect()
- } else {
- // const MAX: usize = 8192;
- const MAX: usize = 100_000;
- files.take(MAX).collect()
- };
- files.sort();
-
log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
- Picker::new(files, root, move |cx, path: &PathBuf, action| {
+ let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| {
if let Err(e) = cx.editor.open(path, action) {
let err = if let Some(err) = e.source() {
format!("{}", err)
@@ -225,20 +209,27 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
cx.editor.set_error(err);
}
})
- .with_preview(|_editor, path| Some((path.clone().into(), None)))
+ .with_preview(|_editor, path| Some((path.clone().into(), None)));
+ let injector = picker.injector();
+ std::thread::spawn(move || {
+ for file in files {
+ if injector.push(file).is_err() {
+ break;
+ }
+ }
+ });
+ picker
}
pub mod completers {
use crate::ui::prompt::Completion;
- use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
- use fuzzy_matcher::FuzzyMatcher;
+ use helix_core::fuzzy::fuzzy_match;
use helix_core::syntax::LanguageServerFeature;
use helix_view::document::SCRATCH_BUFFER_NAME;
use helix_view::theme;
use helix_view::{editor::Config, Editor};
use once_cell::sync::Lazy;
use std::borrow::Cow;
- use std::cmp::Reverse;
pub type Completer = fn(&Editor, &str) -> Vec<Completion>;
@@ -247,31 +238,16 @@ pub mod completers {
}
pub fn buffer(editor: &Editor, input: &str) -> Vec<Completion> {
- let mut names: Vec<_> = editor
- .documents
- .values()
- .map(|doc| {
- let name = doc
- .relative_path()
- .map(|p| p.display().to_string())
- .unwrap_or_else(|| String::from(SCRATCH_BUFFER_NAME));
- ((0..), Cow::from(name))
- })
- .collect();
-
- let matcher = Matcher::default();
-
- let mut matches: Vec<_> = names
- .into_iter()
- .filter_map(|(_range, name)| {
- matcher.fuzzy_match(&name, input).map(|score| (name, score))
- })
- .collect();
-
- matches.sort_unstable_by_key(|(_file, score)| Reverse(*score));
- names = matches.into_iter().map(|(name, _)| ((0..), name)).collect();
+ let names = editor.documents.values().map(|doc| {
+ doc.relative_path()
+ .map(|p| p.display().to_string().into())
+ .unwrap_or_else(|| Cow::from(SCRATCH_BUFFER_NAME))
+ });
- names
+ fuzzy_match(input, names, true)
+ .into_iter()
+ .map(|(name, _)| ((0..), name))
+ .collect()
}
pub fn theme(_editor: &Editor, input: &str) -> Vec<Completion> {
@@ -284,26 +260,10 @@ pub mod completers {
names.sort();
names.dedup();
- let mut names: Vec<_> = names
+ fuzzy_match(input, names, false)
.into_iter()
- .map(|name| ((0..), Cow::from(name)))
- .collect();
-
- let matcher = Matcher::default();
-
- let mut matches: Vec<_> = names
- .into_iter()
- .filter_map(|(_range, name)| {
- matcher.fuzzy_match(&name, input).map(|score| (name, score))
- })
- .collect();
-
- matches.sort_unstable_by(|(name1, score1), (name2, score2)| {
- (Reverse(*score1), name1).cmp(&(Reverse(*score2), name2))
- });
- names = matches.into_iter().map(|(name, _)| ((0..), name)).collect();
-
- names
+ .map(|(name, _)| ((0..), name.into()))
+ .collect()
}
/// Recursive function to get all keys from this value and add them to vec
@@ -330,15 +290,7 @@ pub mod completers {
keys
});
- let matcher = Matcher::default();
-
- let mut matches: Vec<_> = KEYS
- .iter()
- .filter_map(|name| matcher.fuzzy_match(name, input).map(|score| (name, score)))
- .collect();
-
- matches.sort_unstable_by_key(|(_file, score)| Reverse(*score));
- matches
+ fuzzy_match(input, &*KEYS, false)
.into_iter()
.map(|(name, _)| ((0..), name.into()))
.collect()
@@ -365,8 +317,6 @@ pub mod completers {
}
pub fn language(editor: &Editor, input: &str) -> Vec<Completion> {
- let matcher = Matcher::default();
-
let text: String = "text".into();
let language_ids = editor
@@ -375,27 +325,13 @@ pub mod completers {
.map(|config| &config.language_id)
.chain(std::iter::once(&text));
- let mut matches: Vec<_> = language_ids
- .filter_map(|language_id| {
- matcher
- .fuzzy_match(language_id, input)
- .map(|score| (language_id, score))
- })
- .collect();
-
- matches.sort_unstable_by(|(language1, score1), (language2, score2)| {
- (Reverse(*score1), language1).cmp(&(Reverse(*score2), language2))
- });
-
- matches
+ fuzzy_match(input, language_ids, false)
.into_iter()
- .map(|(language, _score)| ((0..), language.clone().into()))
+ .map(|(name, _)| ((0..), name.to_owned().into()))
.collect()
}
pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec<Completion> {
- let matcher = Matcher::default();
-
let Some(options) = doc!(editor)
.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
.find_map(|ls| ls.capabilities().execute_command_provider.as_ref())
@@ -403,23 +339,9 @@ pub mod completers {
return vec![];
};
- let mut matches: Vec<_> = options
- .commands
- .iter()
- .filter_map(|command| {
- matcher
- .fuzzy_match(command, input)
- .map(|score| (command, score))
- })
- .collect();
-
- matches.sort_unstable_by(|(command1, score1), (command2, score2)| {
- (Reverse(*score1), command1).cmp(&(Reverse(*score2), command2))
- });
-
- matches
+ fuzzy_match(input, &options.commands, false)
.into_iter()
- .map(|(command, _score)| ((0..), command.clone().into()))
+ .map(|(name, _)| ((0..), name.to_owned().into()))
.collect()
}
@@ -500,7 +422,7 @@ pub mod completers {
let end = input.len()..;
- let mut files: Vec<_> = WalkBuilder::new(&dir)
+ let files = WalkBuilder::new(&dir)
.hidden(false)
.follow_links(false) // We're scanning over depth 1
.git_ignore(git_ignore)
@@ -532,43 +454,25 @@ pub mod completers {
path.push("");
}
- let path = path.to_str()?.to_owned();
- Some((end.clone(), Cow::from(path)))
+ let path = path.into_os_string().into_string().ok()?;
+ Some(Cow::from(path))
})
}) // TODO: unwrap or skip
- .filter(|(_, path)| !path.is_empty()) // TODO
- .collect();
+ .filter(|path| !path.is_empty());
// if empty, return a list of dirs and files in current dir
if let Some(file_name) = file_name {
- let matcher = Matcher::default();
-
- // inefficient, but we need to calculate the scores, filter out None, then sort.
- let mut matches: Vec<_> = files
- .into_iter()
- .filter_map(|(_range, file)| {
- matcher
- .fuzzy_match(&file, &file_name)
- .map(|score| (file, score))
- })
- .collect();
-
let range = (input.len().saturating_sub(file_name.len()))..;
-
- matches.sort_unstable_by(|(file1, score1), (file2, score2)| {
- (Reverse(*score1), file1).cmp(&(Reverse(*score2), file2))
- });
-
- files = matches
+ fuzzy_match(&file_name, files, true)
.into_iter()
- .map(|(file, _)| (range.clone(), file))
- .collect();
+ .map(|(name, _)| (range.clone(), name))
+ .collect()
// TODO: complete to longest common match
} else {
+ let mut files: Vec<_> = files.map(|file| (end.clone(), file)).collect();
files.sort_unstable_by(|(_, path1), (_, path2)| path1.cmp(path2));
+ files
}
-
- files
}
}
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index b134eb47..3073a697 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -7,11 +7,12 @@ use crate::{
ui::{
self,
document::{render_document, LineDecoration, LinePos, TextRenderer},
- fuzzy_match::FuzzyQuery,
EditorView,
},
};
use futures_util::{future::BoxFuture, FutureExt};
+use nucleo::pattern::CaseMatching;
+use nucleo::{Config, Nucleo, Utf32String};
use tui::{
buffer::Buffer as Surface,
layout::Constraint,
@@ -19,16 +20,23 @@ use tui::{
widgets::{Block, BorderType, Borders, Cell, Table},
};
-use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use tui::widgets::Widget;
-use std::cmp::{self, Ordering};
-use std::{collections::HashMap, io::Read, path::PathBuf};
+use std::{
+ collections::HashMap,
+ io::Read,
+ path::PathBuf,
+ sync::{
+ atomic::{self, AtomicBool},
+ Arc,
+ },
+};
use crate::ui::{Prompt, PromptEvent};
use helix_core::{
- char_idx_at_visual_offset, movement::Direction, text_annotations::TextAnnotations,
- unicode::segmentation::UnicodeSegmentation, Position, Syntax,
+ char_idx_at_visual_offset, fuzzy::MATCHER, movement::Direction,
+ text_annotations::TextAnnotations, unicode::segmentation::UnicodeSegmentation, Position,
+ Syntax,
};
use helix_view::{
editor::Action,
@@ -114,20 +122,71 @@ impl Preview<'_, '_> {
}
}
+fn item_to_nucleo<T: Item>(item: T, editor_data: &T::Data) -> Option<(T, Utf32String)> {
+ let row = item.format(editor_data);
+ let mut cells = row.cells.iter();
+ let mut text = String::with_capacity(row.cell_text().map(|cell| cell.len()).sum());
+ let cell = cells.next()?;
+ if let Some(cell) = cell.content.lines.first() {
+ for span in &cell.0 {
+ text.push_str(&span.content);
+ }
+ }
+
+ for cell in cells {
+ text.push(' ');
+ if let Some(cell) = cell.content.lines.first() {
+ for span in &cell.0 {
+ text.push_str(&span.content);
+ }
+ }
+ }
+ Some((item, text.into()))
+}
+
+pub struct Injector<T: Item> {
+ dst: nucleo::Injector<T>,
+ editor_data: Arc<T::Data>,
+ shutown: Arc<AtomicBool>,
+}
+
+impl<T: Item> Clone for Injector<T> {
+ fn clone(&self) -> Self {
+ Injector {
+ dst: self.dst.clone(),
+ editor_data: self.editor_data.clone(),
+ shutown: Arc::new(AtomicBool::new(false)),
+ }
+ }
+}
+
+pub struct InjectorShutdown;
+
+impl<T: Item> Injector<T> {
+ pub fn push(&self, item: T) -> Result<(), InjectorShutdown> {
+ if self.shutown.load(atomic::Ordering::Relaxed) {
+ return Err(InjectorShutdown);
+ }
+
+ if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) {
+ self.dst.push(item, |dst| dst[0] = matcher_text);
+ }
+ Ok(())
+ }
+}
+
pub struct Picker<T: Item> {
- options: Vec<T>,
- editor_data: T::Data,
- // filter: String,
- matcher: Box<Matcher>,
- matches: Vec<PickerMatch>,
+ editor_data: Arc<T::Data>,
+ shutdown: Arc<AtomicBool>,
+ matcher: Nucleo<T>,
/// Current height of the completions box
completion_height: u16,
- cursor: usize,
- // pattern: String,
+ cursor: u32,
prompt: Prompt,
- previous_pattern: (String, FuzzyQuery),
+ previous_pattern: String,
+
/// Whether to show the preview panel (default true)
show_preview: bool,
/// Constraints for tabular formatting
@@ -144,11 +203,60 @@ pub struct Picker<T: Item> {
}
impl<T: Item + 'static> Picker<T> {
+ pub fn stream(editor_data: T::Data) -> (Nucleo<T>, Injector<T>) {
+ let matcher = Nucleo::new(
+ Config::DEFAULT,
+ Arc::new(helix_event::request_redraw),
+ None,
+ 1,
+ );
+ let streamer = Injector {
+ dst: matcher.injector(),
+ editor_data: Arc::new(editor_data),
+ shutown: Arc::new(AtomicBool::new(false)),
+ };
+ (matcher, streamer)
+ }
+
pub fn new(
options: Vec<T>,
editor_data: T::Data,
callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
) -> Self {
+ let matcher = Nucleo::new(
+ Config::DEFAULT,
+ Arc::new(helix_event::request_redraw),
+ None,
+ 1,
+ );
+ let injector = matcher.injector();
+ for item in options {
+ if let Some((item, matcher_text)) = item_to_nucleo(item, &editor_data) {
+ injector.push(item, |dst| dst[0] = matcher_text);
+ }
+ }
+ Self::with(
+ matcher,
+ Arc::new(editor_data),
+ Arc::new(AtomicBool::new(false)),
+ callback_fn,
+ )
+ }
+
+ pub fn with_stream(
+ matcher: Nucleo<T>,
+ injector: Injector<T>,
+ callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
+ ) -> Self {
+ Self::with(matcher, injector.editor_data, injector.shutown, callback_fn)
+ }
+
+ fn with(
+ matcher: Nucleo<T>,
+ editor_data: Arc<T::Data>,
+ shutdown: Arc<AtomicBool>,
+ callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
+ ) -> Self {
let prompt = Prompt::new(
"".into(),
None,
@@ -156,14 +264,13 @@ impl<T: Item + 'static> Picker<T> {
|_editor: &mut Context, _pattern: &str, _event: PromptEvent| {},
);
- let mut picker = Self {
- options,
+ Self {
+ matcher,
editor_data,
- matcher: Box::default(),
- matches: Vec::new(),
+ shutdown,
cursor: 0,
prompt,
- previous_pattern: (String::new(), FuzzyQuery::default()),
+ previous_pattern: String::new(),
truncate_start: true,
show_preview: true,
callback_fn: Box::new(callback_fn),
@@ -172,24 +279,15 @@ impl<T: Item + 'static> Picker<T> {
preview_cache: HashMap::new(),
read_buffer: Vec::with_capacity(1024),
file_fn: None,
- };
-
- picker.calculate_column_widths();
-
- // scoring on empty input
- // TODO: just reuse score()
- picker
- .matches
- .extend(picker.options.iter().enumerate().map(|(index, option)| {
- let text = option.filter_text(&picker.editor_data);
- PickerMatch {
- index,
- score: 0,
- len: text.chars().count(),
- }
- }));
+ }
+ }
- picker
+ pub fn injector(&self) -> Injector<T> {
+ Injector {
+ dst: self.matcher.injector(),
+ editor_data: self.editor_data.clone(),
+ shutown: self.shutdown.clone(),
+ }
}
pub fn truncate_start(mut self, truncate_start: bool) -> Self {
@@ -202,122 +300,25 @@ impl<T: Item + 'static> Picker<T> {
preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
) -> Self {
self.file_fn = Some(Box::new(preview_fn));
+ // assumption: if we have a preview we are matching paths... If this is ever
+ // not true this could be a separate builder function
+ self.matcher.update_config(Config::DEFAULT.match_paths());
self
}
pub fn set_options(&mut self, new_options: Vec<T>) {
- self.options = new_options;
- self.cursor = 0;
- self.force_score();
- self.calculate_column_widths();
- }
-
- /// Calculate the width constraints using the maximum widths of each column
- /// for the current options.
- fn calculate_column_widths(&mut self) {
- let n = self
- .options
- .first()
- .map(|option| option.format(&self.editor_data).cells.len())
- .unwrap_or_default();
- let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| {
- let row = option.format(&self.editor_data);
- // maintain max for each column
- for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) {
- let width = cell.content.width();
- if width > *acc {
- *acc = width;
- }
+ self.matcher.restart(false);
+ let injector = self.matcher.injector();
+ for item in new_options {
+ if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) {
+ injector.push(item, |dst| dst[0] = matcher_text);
}
- acc
- });
- self.widths = max_lens
- .into_iter()
- .map(|len| Constraint::Length(len as u16))
- .collect();
- }
-
- pub fn score(&mut self) {
- let pattern = self.prompt.line();
-
- if pattern == &self.previous_pattern.0 {
- return;
}
-
- let (query, is_refined) = self
- .previous_pattern
- .1
- .refine(pattern, &self.previous_pattern.0);
-
- if pattern.is_empty() {
- // Fast path for no pattern.
- self.matches.clear();
- self.matches
- .extend(self.options.iter().enumerate().map(|(index, option)| {
- let text = option.filter_text(&self.editor_data);
- PickerMatch {
- index,
- score: 0,
- len: text.chars().count(),
- }
- }));
- } else if is_refined {
- // optimization: if the pattern is a more specific version of the previous one
- // then we can score the filtered set.
- self.matches.retain_mut(|pmatch| {
- let option = &self.options[pmatch.index];
- let text = option.sort_text(&self.editor_data);
-
- match query.fuzzy_match(&text, &self.matcher) {
- Some(s) => {
- // Update the score
- pmatch.score = s;
- true
- }
- None => false,
- }
- });
-
- self.matches.sort_unstable();
- } else {
- self.force_score();
- }
-
- // reset cursor position
- self.cursor = 0;
- let pattern = self.prompt.line();
- self.previous_pattern.0.clone_from(pattern);
- self.previous_pattern.1 = query;
- }
-
- pub fn force_score(&mut self) {
- let pattern = self.prompt.line();
-
- let query = FuzzyQuery::new(pattern);
- self.matches.clear();
- self.matches.extend(
- self.options
- .iter()
- .enumerate()
- .filter_map(|(index, option)| {
- let text = option.filter_text(&self.editor_data);
-
- query
- .fuzzy_match(&text, &self.matcher)
- .map(|score| PickerMatch {
- index,
- score,
- len: text.chars().count(),
- })
- }),
- );
-
- self.matches.sort_unstable();
}
/// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`)
- pub fn move_by(&mut self, amount: usize, direction: Direction) {
- let len = self.matches.len();
+ pub fn move_by(&mut self, amount: u32, direction: Direction) {
+ let len = self.matcher.snapshot().matched_item_count();
if len == 0 {
// No results, can't move.
@@ -336,12 +337,12 @@ impl<T: Item + 'static> Picker<T> {
/// Move the cursor down by exactly one page. After the last page comes the first page.
pub fn page_up(&mut self) {
- self.move_by(self.completion_height as usize, Direction::Backward);
+ self.move_by(self.completion_height as u32, Direction::Backward);
}
/// Move the cursor up by exactly one page. After the first page comes the last page.
pub fn page_down(&mut self) {
- self.move_by(self.completion_height as usize, Direction::Forward);
+ self.move_by(self.completion_height as u32, Direction::Forward);
}
/// Move the cursor to the first entry
@@ -351,13 +352,18 @@ impl<T: Item + 'static> Picker<T> {
/// Move the cursor to the last entry
pub fn to_end(&mut self) {
- self.cursor = self.matches.len().saturating_sub(1);
+ self.cursor = self
+ .matcher
+ .snapshot()
+ .matched_item_count()
+ .saturating_sub(1);
}
pub fn selection(&self) -> Option<&T> {
- self.matches
- .get(self.cursor)
- .map(|pmatch| &self.options[pmatch.index])
+ self.matcher
+ .snapshot()
+ .get_matched_item(self.cursor)
+ .map(|item| item.data)
}
pub fn toggle_preview(&mut self) {
@@ -366,8 +372,17 @@ impl<T: Item + 'static> Picker<T> {
fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
- // TODO: recalculate only if pattern changed
- self.score();
+ let pattern = self.prompt.line();
+ // TODO: better track how the pattern has changed
+ if pattern != &self.previous_pattern {
+ self.matcher.pattern.reparse(
+ 0,
+ pattern,
+ CaseMatching::Smart,
+ pattern.starts_with(&self.previous_pattern),
+ );
+ self.previous_pattern = pattern.clone();
+ }
}
EventResult::Consumed(None)
}
@@ -411,12 +426,9 @@ impl<T: Item + 'static> Picker<T> {
(size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
CachedPreview::LargeFile
}
- _ => {
- // TODO: enable syntax highlighting; blocked by async rendering
- Document::open(path, None, None, editor.config.clone())
- .map(|doc| CachedPreview::Document(Box::new(doc)))
- .unwrap_or(CachedPreview::NotFound)
- }
+ _ => Document::open(path, None, None, editor.config.clone())
+ .map(|doc| CachedPreview::Document(Box::new(doc)))
+ .unwrap_or(CachedPreview::NotFound),
},
)
.unwrap_or(CachedPreview::NotFound);
@@ -495,6 +507,14 @@ impl<T: Item + 'static> Picker<T> {
}
fn render_picker(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ let status = self.matcher.tick(10);
+ let snapshot = self.matcher.snapshot();
+ if status.changed {
+ self.cursor = self
+ .cursor
+ .min(snapshot.matched_item_count().saturating_sub(1))
+ }
+
let text_style = cx.editor.theme.get("ui.text");
let selected = cx.editor.theme.get("ui.text.focus");
let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD);
@@ -515,8 +535,15 @@ impl<T: Item + 'static> Picker<T> {
// -- Render the input bar:
let area = inner.clip_left(1).with_height(1);
+ // render the prompt first since it will clear its background
+ self.prompt.render(area, surface, cx);
- let count = format!("{}/{}", self.matches.len(), self.options.len());
+ let count = format!(
+ "{}{}/{}",
+ if status.running { "(running) " } else { "" },
+ snapshot.matched_item_count(),
+ snapshot.item_count(),
+ );
surface.set_stringn(
(area.x + area.width).saturating_sub(count.len() as u16 + 1),
area.y,
@@ -525,8 +552,6 @@ impl<T: Item + 'static> Picker<T> {
text_style,
);
- self.prompt.render(area, surface, cx);
-
// -- Separator
let sep_style = cx.editor.theme.get("ui.background.separator");
let borders = BorderType::line_symbols(BorderType::Plain);
@@ -539,106 +564,89 @@ impl<T: Item + 'static> Picker<T> {
// -- Render the contents:
// subtract area of prompt from top
let inner = inner.clip_top(2);
-
- let rows = inner.height;
- let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize));
+ let rows = inner.height as u32;
+ let offset = self.cursor - (self.cursor % std::cmp::max(1, rows));
let cursor = self.cursor.saturating_sub(offset);
+ let end = offset
+ .saturating_add(rows)
+ .min(snapshot.matched_item_count());
+ let mut indices = Vec::new();
+ let mut matcher = MATCHER.lock();
+ matcher.config = Config::DEFAULT;
+ if self.file_fn.is_some() {
+ matcher.config.set_match_paths()
+ }
- let options = self
- .matches
- .iter()
- .skip(offset)
- .take(rows as usize)
- .map(|pmatch| &self.options[pmatch.index])
- .map(|option| option.format(&self.editor_data))
- .map(|mut row| {
- const TEMP_CELL_SEP: &str = " ";
-
- let line = row.cell_text().fold(String::new(), |mut s, frag| {
- s.push_str(&frag);
- s.push_str(TEMP_CELL_SEP);
- s
- });
-
- // Items are filtered by using the text returned by menu::Item::filter_text
- // but we do highlighting here using the text in Row and therefore there
- // might be inconsistencies. This is the best we can do since only the
- // text in Row is displayed to the end user.
- let (_score, highlights) = FuzzyQuery::new(self.prompt.line())
- .fuzzy_indices(&line, &self.matcher)
- .unwrap_or_default();
-
- let highlight_byte_ranges: Vec<_> = line
- .char_indices()
- .enumerate()
- .filter_map(|(char_idx, (byte_offset, ch))| {
- highlights
- .contains(&char_idx)
- .then(|| byte_offset..byte_offset + ch.len_utf8())
- })
- .collect();
-
- // The starting byte index of the current (iterating) cell
- let mut cell_start_byte_offset = 0;
- for cell in row.cells.iter_mut() {
- let spans = match cell.content.lines.get(0) {
- Some(s) => s,
- None => {
- cell_start_byte_offset += TEMP_CELL_SEP.len();
- continue;
- }
- };
+ let options = snapshot.matched_items(offset..end).map(|item| {
+ snapshot.pattern().column_pattern(0).indices(
+ item.matcher_columns[0].slice(..),
+ &mut matcher,
+ &mut indices,
+ );
+ indices.sort_unstable();
+ indices.dedup();
+ let mut row = item.data.format(&self.editor_data);
+
+ let mut grapheme_idx = 0u32;
+ let mut indices = indices.drain(..);
+ let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
+ if self.widths.len() < row.cells.len() {
+ self.widths.resize(row.cells.len(), Constraint::Length(0));
+ }
+ let mut widths = self.widths.iter_mut();
+ for cell in &mut row.cells {
+ let Some(Constraint::Length(max_width)) = widths.next() else {
+ unreachable!();
+ };
- let mut cell_len = 0;
-
- let graphemes_with_style: Vec<_> = spans
- .0
- .iter()
- .flat_map(|span| {
- span.content
- .grapheme_indices(true)
- .zip(std::iter::repeat(span.style))
- })
- .map(|((grapheme_byte_offset, grapheme), style)| {
- cell_len += grapheme.len();
- let start = cell_start_byte_offset;
-
- let grapheme_byte_range =
- grapheme_byte_offset..grapheme_byte_offset + grapheme.len();
-
- if highlight_byte_ranges.iter().any(|hl_rng| {
- hl_rng.start >= start + grapheme_byte_range.start
- && hl_rng.end <= start + grapheme_byte_range.end
- }) {
- (grapheme, style.patch(highlight_style))
- } else {
- (grapheme, style)
- }
- })
- .collect();
-
- let mut span_list: Vec<(String, Style)> = Vec::new();
- for (grapheme, style) in graphemes_with_style {
- if span_list.last().map(|(_, sty)| sty) == Some(&style) {
- let (string, _) = span_list.last_mut().unwrap();
- string.push_str(grapheme);
+ // merge index highlights on top of existing hightlights
+ let mut span_list = Vec::new();
+ let mut current_span = String::new();
+ let mut current_style = Style::default();
+ let mut width = 0;
+
+ let spans: &[Span] = cell.content.lines.first().map_or(&[], |it| it.0.as_slice());
+ for span in spans {
+ // this looks like a bug on first glance, we are iterating
+ // graphemes but treating them as char indices. The reason that
+ // this is correct is that nucleo will only ever consider the first char
+ // of a grapheme (and discard the rest of the grapheme) so the indices
+ // returned by nucleo are essentially grapheme indecies
+ for grapheme in span.content.graphemes(true) {
+ let style = if grapheme_idx == next_highlight_idx {
+ next_highlight_idx = indices.next().unwrap_or(u32::MAX);
+ span.style.patch(highlight_style)
} else {
- span_list.push((String::from(grapheme), style))
+ span.style
+ };
+ if style != current_style {
+ if !current_span.is_empty() {
+ span_list.push(Span::styled(current_span, current_style))
+ }
+ current_span = String::new();
+ current_style = style;
}
+ current_span.push_str(grapheme);
+ grapheme_idx += 1;
}
+ width += span.width();
+ }
- let spans: Vec<Span> = span_list
- .into_iter()
- .map(|(string, style)| Span::styled(string, style))
- .collect();
- let spans: Spans = spans.into();
- *cell = Cell::from(spans);
+ span_list.push(Span::styled(current_span, current_style));
+ if width as u16 > *max_width {
+ *max_width = width as u16;
+ }
+ *cell = Cell::from(Spans::from(span_list));
- cell_start_byte_offset += cell_len + TEMP_CELL_SEP.len();
+ // spacer
+ if grapheme_idx == next_highlight_idx {
+ next_highlight_idx = indices.next().unwrap_or(u32::MAX);
}
+ grapheme_idx += 1;
+ }
- row
- });
+ row
+ });
let table = Table::new(options)
.style(text_style)
@@ -654,7 +662,7 @@ impl<T: Item + 'static> Picker<T> {
surface,
&mut TableState {
offset: 0,
- selected: Some(cursor),
+ selected: Some(cursor as usize),
},
self.truncate_start,
);
@@ -755,7 +763,7 @@ impl<T: Item + 'static> Picker<T> {
}
}
-impl<T: Item + 'static> Component for Picker<T> {
+impl<T: Item + 'static + Send + Sync> Component for Picker<T> {
fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
// +---------+ +---------+
// |prompt | |preview |
@@ -875,29 +883,10 @@ impl<T: Item + 'static> Component for Picker<T> {
Some((width, height))
}
}
-
-#[derive(PartialEq, Eq, Debug)]
-struct PickerMatch {
- score: i64,
- index: usize,
- len: usize,
-}
-
-impl PickerMatch {
- fn key(&self) -> impl Ord {
- (cmp::Reverse(self.score), self.len, self.index)
- }
-}
-
-impl PartialOrd for PickerMatch {
- fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for PickerMatch {
- fn cmp(&self, other: &Self) -> Ordering {
- self.key().cmp(&other.key())
+impl<T: Item> Drop for Picker<T> {
+ fn drop(&mut self) {
+ // ensure we cancel any ongoing background threads streaming into the picker
+ self.shutdown.store(true, atomic::Ordering::Relaxed)
}
}
@@ -910,13 +899,13 @@ pub type DynQueryCallback<T> =
/// A picker that updates its contents via a callback whenever the
/// query string changes. Useful for live grep, workspace symbols, etc.
-pub struct DynamicPicker<T: ui::menu::Item + Send> {
+pub struct DynamicPicker<T: ui::menu::Item + Send + Sync> {
file_picker: Picker<T>,
query_callback: DynQueryCallback<T>,
query: String,
}
-impl<T: ui::menu::Item + Send> DynamicPicker<T> {
+impl<T: ui::menu::Item + Send + Sync> DynamicPicker<T> {
pub const ID: &'static str = "dynamic-picker";
pub fn new(file_picker: Picker<T>, query_callback: DynQueryCallback<T>) -> Self {
@@ -928,7 +917,7 @@ impl<T: ui::menu::Item + Send> DynamicPicker<T> {
}
}
-impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
+impl<T: Item + Send + Sync + 'static> Component for DynamicPicker<T> {
fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
self.file_picker.render(area, surface, cx);
}