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 <107059409+ItsEthra@users.noreply.github.com>
This commit is contained in:
Michael Davis 2024-02-16 10:57:38 -05:00
parent f40fca88e0
commit c4c17c693d
No known key found for this signature in database
2 changed files with 324 additions and 11 deletions

View File

@ -1,4 +1,5 @@
mod handlers;
mod query;
use crate::{
alt,
@ -9,6 +10,7 @@
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 @@ fn with(
.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 @@ fn with(
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 @@ pub fn selection(&self) -> Option<&T> {
.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 @@ fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResul
}
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);
}
}

View File

@ -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()
)
);
}
}