aboutsummaryrefslogtreecommitdiff
path: root/helix-term/src/ui/picker.rs
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term/src/ui/picker.rs')
-rw-r--r--helix-term/src/ui/picker.rs545
1 files changed, 267 insertions, 278 deletions
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);
}