Skip to content

Commit

Permalink
Picker: Highlight the currently active column
Browse files Browse the repository at this point in the history
We can track the ranges in the input text that correspond to each column
and use this information during rendering to apply a new theme key that
makes the "active column" stand out. This makes it easier to tell at
a glance which column you're entering.
  • Loading branch information
the-mikedavis committed Jul 15, 2024
1 parent a7777b3 commit 9de5f5c
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 5 deletions.
1 change: 1 addition & 0 deletions book/src/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ These scopes are used for theming the editor interface:
| `ui.popup` | Documentation popups (e.g. Space + k) |
| `ui.popup.info` | Prompt for multiple key options |
| `ui.picker.header` | Column names in pickers with multiple columns |
| `ui.picker.header.active` | The column name in pickers with multiple columns where the cursor is entering into. |
| `ui.window` | Borderlines separating splits |
| `ui.help` | Description box for commands |
| `ui.text` | Default text style, command prompts, popup text, etc. |
Expand Down
10 changes: 9 additions & 1 deletion helix-term/src/ui/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,13 +787,21 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {

// -- Header
if self.columns.len() > 1 {
let active_column = self.query.active_column(self.prompt.position());
let header_style = cx.editor.theme.get("ui.picker.header");

table = table.header(Row::new(self.columns.iter().map(|column| {
if column.hidden {
Cell::default()
} else {
Cell::from(Span::styled(Cow::from(&*column.name), header_style))
let style = if active_column.is_some_and(|name| Arc::ptr_eq(name, &column.name))
{
cx.editor.theme.get("ui.picker.header.active")
} else {
header_style
};

Cell::from(Span::styled(Cow::from(&*column.name), style))
}
})));
}
Expand Down
94 changes: 90 additions & 4 deletions helix-term/src/ui/picker/query.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{collections::HashMap, mem, sync::Arc};
use std::{collections::HashMap, mem, ops::Range, sync::Arc};

#[derive(Debug)]
pub(super) struct PickerQuery {
Expand All @@ -11,6 +11,10 @@ pub(super) struct PickerQuery {
/// The mapping between column names and input in the query
/// for those columns.
inner: HashMap<Arc<str>, Arc<str>>,
/// The byte ranges of the input text which are used as input for each column.
/// This is calculated at parsing time for use in [Self::active_column].
/// This Vec is naturally sorted in ascending order and ranges do not overlap.
column_ranges: Vec<(Range<usize>, Option<Arc<str>>)>,
}

impl PartialEq<HashMap<Arc<str>, Arc<str>>> for PickerQuery {
Expand All @@ -26,10 +30,12 @@ impl PickerQuery {
) -> Self {
let column_names: Box<[_]> = column_names.collect();
let inner = HashMap::with_capacity(column_names.len());
let column_ranges = vec![(0..usize::MAX, Some(column_names[primary_column].clone()))];
Self {
column_names,
primary_column,
inner,
column_ranges,
}
}

Expand All @@ -44,6 +50,9 @@ impl PickerQuery {
let mut in_field = false;
let mut field = None;
let mut text = String::new();
self.column_ranges.clear();
self.column_ranges
.push((0..usize::MAX, Some(primary_field.clone())));

macro_rules! finish_field {
() => {
Expand All @@ -59,7 +68,7 @@ impl PickerQuery {
};
}

for ch in input.chars() {
for (idx, ch) in input.char_indices() {
match ch {
// Backslash escaping
_ if escaped => {
Expand All @@ -77,9 +86,19 @@ impl PickerQuery {
if !text.is_empty() {
finish_field!();
}
let (range, _field) = self
.column_ranges
.last_mut()
.expect("column_ranges is non-empty");
range.end = idx;
in_field = true;
}
' ' if in_field => {
text.clear();
in_field = false;
}
_ if in_field => {
text.push(ch);
// Go over all columns and their indices, find all that starts with field key,
// select a column that fits key the most.
field = self
Expand All @@ -88,8 +107,17 @@ impl PickerQuery {
.filter(|col| col.starts_with(&text))
// select "fittest" column
.min_by_key(|col| col.len());
text.clear();
in_field = false;

// Update the column range for this column.
if let Some((_range, current_field)) = self
.column_ranges
.last_mut()
.filter(|(range, _)| range.end == usize::MAX)
{
*current_field = field.cloned();
} else {
self.column_ranges.push((idx..usize::MAX, field.cloned()));
}
}
_ => text.push(ch),
}
Expand All @@ -106,6 +134,23 @@ impl PickerQuery {

mem::replace(&mut self.inner, new_inner)
}

/// Finds the column which the cursor is 'within' in the last parse.
///
/// The cursor is considered to be within a column when it is placed within any
/// of a column's text. See the `active_column_test` unit test below for examples.
///
/// `cursor` is a byte index that represents the location of the prompt's cursor.
pub fn active_column(&self, cursor: usize) -> Option<&Arc<str>> {
let point = self
.column_ranges
.partition_point(|(range, _field)| cursor > range.end);

self.column_ranges
.get(point)
.filter(|(range, _field)| cursor >= range.start && cursor <= range.end)
.and_then(|(_range, field)| field.as_ref())
}
}

#[cfg(test)]
Expand Down Expand Up @@ -279,4 +324,45 @@ mod test {
)
);
}

#[test]
fn active_column_test() {
fn active_column<'a>(query: &'a mut PickerQuery, input: &str) -> Option<&'a str> {
let cursor = input.find('|').expect("cursor must be indicated with '|'");
let input = input.replace('|', "");
query.parse(&input);
query.active_column(cursor).map(AsRef::as_ref)
}

let mut query = PickerQuery::new(
["primary".into(), "foo".into(), "bar".into()].into_iter(),
0,
);

assert_eq!(active_column(&mut query, "|"), Some("primary"));
assert_eq!(active_column(&mut query, "hello| world"), Some("primary"));
assert_eq!(active_column(&mut query, "|%foo hello"), Some("primary"));
assert_eq!(active_column(&mut query, "%foo|"), Some("foo"));
assert_eq!(active_column(&mut query, "%|"), None);
assert_eq!(active_column(&mut query, "%baz|"), None);
assert_eq!(active_column(&mut query, "%quiz%|"), None);
assert_eq!(active_column(&mut query, "%foo hello| world"), Some("foo"));
assert_eq!(active_column(&mut query, "%foo hello world|"), Some("foo"));
assert_eq!(active_column(&mut query, "%foo| hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "%|foo hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "%f|oo hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "hello %f|oo world"), Some("foo"));
assert_eq!(
active_column(&mut query, "hello %f|oo world %bar !"),
Some("foo")
);
assert_eq!(
active_column(&mut query, "hello %foo wo|rld %bar !"),
Some("foo")
);
assert_eq!(
active_column(&mut query, "hello %foo world %bar !|"),
Some("bar")
);
}
}
6 changes: 6 additions & 0 deletions helix-term/src/ui/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ impl Prompt {
}
}

/// Gets the byte index in the input representing the current cursor location.
#[inline]
pub(crate) fn position(&self) -> usize {
self.cursor
}

pub fn with_line(mut self, line: String, editor: &Editor) -> Self {
self.set_line(line, editor);
self
Expand Down

0 comments on commit 9de5f5c

Please sign in to comment.