diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ab8e4e155..5e7bda21b 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,4 +1,5 @@ mod handlers; +mod query; use crate::{ alt, @@ -9,6 +10,7 @@ use crate::{ ui::{ self, document::{render_document, LineDecoration, LinePos, TextRenderer}, + picker::query::PickerQuery, EditorView, }, }; @@ -226,7 +228,7 @@ pub struct Picker { cursor: u32, prompt: Prompt, - previous_pattern: String, + query: PickerQuery, /// Whether to show the preview panel (default true) show_preview: bool, @@ -331,6 +333,8 @@ impl Picker { .map(|column| Constraint::Length(column.name.chars().count() as u16)) .collect(); + let query = PickerQuery::new(columns.iter().map(|col| &col.name).cloned(), default_column); + Self { columns, primary_column: default_column, @@ -339,7 +343,7 @@ impl Picker { shutdown, cursor: 0, prompt, - previous_pattern: String::new(), + query, truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), @@ -441,6 +445,13 @@ impl Picker { .map(|item| item.data) } + fn primary_query(&self) -> Arc { + self.query + .get(&self.columns[self.primary_column].name) + .cloned() + .unwrap_or_else(|| "".into()) + } + fn header_height(&self) -> u16 { if self.columns.len() > 1 { 1 @@ -461,16 +472,36 @@ impl Picker { } fn handle_prompt_change(&mut self) { - 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(); + let line = self.prompt.line(); + let old_query = self.query.parse(line); + if self.query == old_query { + return; + } + // Have nucleo reparse each changed column. + for (i, column) in self + .columns + .iter() + .filter(|column| column.filter) + .enumerate() + { + let pattern = self + .query + .get(&column.name) + .map(|f| &**f) + .unwrap_or_default(); + let old_pattern = old_query + .get(&column.name) + .map(|f| &**f) + .unwrap_or_default(); + // Fastlane: most columns will remain unchanged after each edit. + if pattern == old_pattern { + continue; + } + let is_append = pattern.starts_with(old_pattern); + self.matcher + .pattern + .reparse(i, pattern, CaseMatching::Smart, is_append); } } diff --git a/helix-term/src/ui/picker/query.rs b/helix-term/src/ui/picker/query.rs new file mode 100644 index 000000000..89ade95ff --- /dev/null +++ b/helix-term/src/ui/picker/query.rs @@ -0,0 +1,282 @@ +use std::{collections::HashMap, mem, sync::Arc}; + +#[derive(Debug)] +pub(super) struct PickerQuery { + /// The column names of the picker. + column_names: Box<[Arc]>, + /// The index of the primary column in `column_names`. + /// The primary column is selected by default unless another + /// field is specified explicitly with `%fieldname`. + primary_column: usize, + /// The mapping between column names and input in the query + /// for those columns. + inner: HashMap, Arc>, +} + +impl PartialEq, Arc>> for PickerQuery { + fn eq(&self, other: &HashMap, Arc>) -> bool { + self.inner.eq(other) + } +} + +impl PickerQuery { + pub(super) fn new>>( + column_names: I, + primary_column: usize, + ) -> Self { + let column_names: Box<[_]> = column_names.collect(); + let inner = HashMap::with_capacity(column_names.len()); + Self { + column_names, + primary_column, + inner, + } + } + + pub(super) fn get(&self, column: &str) -> Option<&Arc> { + self.inner.get(column) + } + + pub(super) fn parse(&mut self, input: &str) -> HashMap, Arc> { + let mut fields: HashMap, String> = HashMap::new(); + let primary_field = &self.column_names[self.primary_column]; + let mut escaped = false; + let mut in_field = false; + let mut field = None; + let mut text = String::new(); + + macro_rules! finish_field { + () => { + let key = field.take().unwrap_or(primary_field); + + if let Some(pattern) = fields.get_mut(key) { + pattern.push(' '); + pattern.push_str(text.trim()); + } else { + fields.insert(key.clone(), text.trim().to_string()); + } + text.clear(); + }; + } + + for ch in input.chars() { + match ch { + // Backslash escaping + _ if escaped => { + // '%' is the only character that is special cased. + // You can escape it to prevent parsing the text that + // follows it as a field name. + if ch != '%' { + text.push('\\'); + } + text.push(ch); + escaped = false; + } + '\\' => escaped = !escaped, + '%' => { + if !text.is_empty() { + finish_field!(); + } + in_field = true; + } + ' ' if in_field => { + // Go over all columns and their indices, find all that starts with field key, + // select a column that fits key the most. + field = self + .column_names + .iter() + .filter(|col| col.starts_with(&text)) + // select "fittest" column + .min_by_key(|col| col.len()); + text.clear(); + in_field = false; + } + _ => text.push(ch), + } + } + + if !in_field && !text.is_empty() { + finish_field!(); + } + + let new_inner: HashMap<_, _> = fields + .into_iter() + .map(|(field, query)| (field, query.as_str().into())) + .collect(); + + mem::replace(&mut self.inner, new_inner) + } +} + +#[cfg(test)] +mod test { + use helix_core::hashmap; + + use super::*; + + #[test] + fn parse_query_test() { + let mut query = PickerQuery::new( + [ + "primary".into(), + "field1".into(), + "field2".into(), + "another".into(), + "anode".into(), + ] + .into_iter(), + 0, + ); + + // Basic field splitting + query.parse("hello world"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello world".into(), + ) + ); + query.parse("hello %field1 world %field2 !"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "world".into(), + "field2".into() => "!".into(), + ) + ); + query.parse("%field1 abc %field2 def xyz"); + assert_eq!( + query, + hashmap!( + "field1".into() => "abc".into(), + "field2".into() => "def xyz".into(), + ) + ); + + // Trailing space is trimmed + query.parse("hello "); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + ) + ); + + // Unknown fields are trimmed. + query.parse("hello %foo"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + ) + ); + + // Multiple words in a field + query.parse("hello %field1 a b c"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "a b c".into(), + ) + ); + + // Escaping + query.parse(r#"hello\ world"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"hello \%field1 world"#); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello %field1 world".into(), + ) + ); + query.parse(r#"%field1 hello\ world"#); + assert_eq!( + query, + hashmap!( + "field1".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"hello %field1 a\"b"#); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => r#"a\"b"#.into(), + ) + ); + query.parse(r#"%field1 hello\ world"#); + assert_eq!( + query, + hashmap!( + "field1".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"\bfoo\b"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"\bfoo\b"#.into(), + ) + ); + query.parse(r#"\\n"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"\\n"#.into(), + ) + ); + + // Only the prefix of a field is required. + query.parse("hello %anot abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "another".into() => "abc".into(), + ) + ); + // The shortest matching the prefix is selected. + query.parse("hello %ano abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "anode".into() => "abc".into() + ) + ); + // Multiple uses of a column are concatenated with space separators. + query.parse("hello %field1 xyz %fie abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "xyz abc".into() + ) + ); + query.parse("hello %fie abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "abc".into() + ) + ); + // The primary column can be explicitly qualified. + query.parse("hello %fie abc %prim world"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello world".into(), + "field1".into() => "abc".into() + ) + ); + } +}