Unnamed repository; edit this file 'description' to name the repository.
Add a special query syntax for Pickers to select columns
Now that the picker is defined as a table, we need a way to provide input for each field in the picker. We introduce a small query syntax that supports multiple columns without being too verbose. Fields are specified as `%field pattern`. The default column for a picker doesn't need the `%field` prefix. The field name may be selected by a prefix of the field, for example `%p foo.rs` rather than `%path foo.rs`. Co-authored-by: ItsEthra <[email protected]>
Michael Davis 2024-07-15
parent f40fca8 · commit c4c17c6
-rw-r--r--helix-term/src/ui/picker.rs53
-rw-r--r--helix-term/src/ui/picker/query.rs282
2 files changed, 324 insertions, 11 deletions
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index ab8e4e15..5e7bda21 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<T: 'static + Send + Sync, D: 'static> {
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<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
.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<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
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<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
.map(|item| item.data)
}
+ fn primary_query(&self) -> Arc<str> {
+ 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<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
}
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 00000000..89ade95f
--- /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<str>]>,
+ /// 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<str>, Arc<str>>,
+}
+
+impl PartialEq<HashMap<Arc<str>, Arc<str>>> for PickerQuery {
+ fn eq(&self, other: &HashMap<Arc<str>, Arc<str>>) -> bool {
+ self.inner.eq(other)
+ }
+}
+
+impl PickerQuery {
+ pub(super) fn new<I: Iterator<Item = Arc<str>>>(
+ 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<str>> {
+ self.inner.get(column)
+ }
+
+ pub(super) fn parse(&mut self, input: &str) -> HashMap<Arc<str>, Arc<str>> {
+ let mut fields: HashMap<Arc<str>, 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()
+ )
+ );
+ }
+}