mirror of
https://github.com/helix-editor/helix.git
synced 2024-11-25 10:56:19 +04:00
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:
parent
f40fca88e0
commit
c4c17c693d
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
282
helix-term/src/ui/picker/query.rs
Normal file
282
helix-term/src/ui/picker/query.rs
Normal 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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user