Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/ui/menu.rs')
-rw-r--r--helix-term/src/ui/menu.rs264
1 files changed, 112 insertions, 152 deletions
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index 41cf96ac..1130089d 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -1,35 +1,35 @@
-use crate::{
- compositor::{Callback, Component, Compositor, Context, Event, EventResult},
- ctrl, key, shift,
-};
+use crate::compositor::{Component, Compositor, Context, EventResult};
+use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use tui::{buffer::Buffer as Surface, widgets::Table};
pub use tui::widgets::{Cell, Row};
-use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
+use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
+use fuzzy_matcher::FuzzyMatcher;
+
+use helix_view::{graphics::Rect, Editor};
use tui::layout::Constraint;
-pub trait Item: Sync + Send + 'static {
- /// Additional editor state that is used for label calculation.
- type Data: Sync + Send + 'static;
+pub trait Item {
+ fn sort_text(&self) -> &str;
+ fn filter_text(&self) -> &str;
- fn format(&self, data: &Self::Data) -> Row<'_>;
+ fn label(&self) -> &str;
+ fn row(&self) -> Row;
}
-pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>;
-
pub struct Menu<T: Item> {
options: Vec<T>,
- editor_data: T::Data,
cursor: Option<usize>,
+ matcher: Box<Matcher>,
/// (index, score)
- matches: Vec<(u32, u32)>,
+ matches: Vec<(usize, i64)>,
widths: Vec<Constraint>,
- callback_fn: MenuCallback<T>,
+ callback_fn: Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>,
scroll: usize,
size: (u16, u16),
@@ -38,20 +38,16 @@ pub struct Menu<T: Item> {
}
impl<T: Item> Menu<T> {
- const LEFT_PADDING: usize = 1;
-
// TODO: it's like a slimmed down picker, share code? (picker = menu + prompt with different
// rendering)
pub fn new(
options: Vec<T>,
- editor_data: <T as Item>::Data,
callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
) -> Self {
- let matches = (0..options.len() as u32).map(|i| (i, 0)).collect();
- Self {
+ let mut menu = Self {
options,
- editor_data,
- matches,
+ matcher: Box::new(Matcher::default()),
+ matches: Vec::new(),
cursor: None,
widths: Vec::new(),
callback_fn: Box::new(callback_fn),
@@ -59,33 +55,39 @@ impl<T: Item> Menu<T> {
size: (0, 0),
viewport: (0, 0),
recalculate: true,
- }
+ };
+
+ // TODO: scoring on empty input should just use a fastpath
+ menu.score("");
+
+ menu
}
- pub fn reset_cursor(&mut self) {
+ pub fn score(&mut self, pattern: &str) {
+ // reuse the matches allocation
+ self.matches.clear();
+ self.matches.extend(
+ self.options
+ .iter()
+ .enumerate()
+ .filter_map(|(index, option)| {
+ let text = option.filter_text();
+ // TODO: using fuzzy_indices could give us the char idx for match highlighting
+ self.matcher
+ .fuzzy_match(text, pattern)
+ .map(|score| (index, score))
+ }),
+ );
+ // matches.sort_unstable_by_key(|(_, score)| -score);
+ self.matches
+ .sort_unstable_by_key(|(index, _score)| self.options[*index].sort_text());
+
+ // reset cursor position
self.cursor = None;
self.scroll = 0;
self.recalculate = true;
}
- pub fn update_options(&mut self) -> (&mut Vec<(u32, u32)>, &mut Vec<T>) {
- self.recalculate = true;
- (&mut self.matches, &mut self.options)
- }
-
- pub fn ensure_cursor_in_bounds(&mut self) {
- if self.matches.is_empty() {
- self.cursor = None;
- self.scroll = 0;
- } else {
- self.scroll = 0;
- self.recalculate = true;
- if let Some(cursor) = &mut self.cursor {
- *cursor = (*cursor).min(self.matches.len() - 1)
- }
- }
- }
-
pub fn clear(&mut self) {
self.matches.clear();
@@ -96,8 +98,7 @@ impl<T: Item> Menu<T> {
pub fn move_up(&mut self) {
let len = self.matches.len();
- let max_index = len.saturating_sub(1);
- let pos = self.cursor.map_or(max_index, |i| (i + max_index) % len) % len;
+ let pos = self.cursor.map_or(0, |i| (i + len.saturating_sub(1)) % len) % len;
self.cursor = Some(pos);
self.adjust_scroll();
}
@@ -113,10 +114,10 @@ impl<T: Item> Menu<T> {
let n = self
.options
.first()
- .map(|option| option.format(&self.editor_data).cells.len())
+ .map(|option| option.row().cells.len())
.unwrap_or_default();
let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| {
- let row = option.format(&self.editor_data);
+ let row = option.row();
// maintain max for each column
for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) {
let width = cell.content.width();
@@ -127,18 +128,7 @@ impl<T: Item> Menu<T> {
acc
});
-
- let height = self.matches.len().min(10).min(viewport.1 as usize);
- // do all the matches fit on a single screen?
- let fits = self.matches.len() <= height;
-
- let mut len = max_lens.iter().sum::<usize>() + n;
-
- if !fits {
- len += 1; // +1: reserve some space for scrollbar
- }
-
- len += Self::LEFT_PADDING;
+ let len = max_lens.iter().sum::<usize>() + n + 1; // +1: reserve some space for scrollbar
let width = len.min(viewport.0 as usize);
self.widths = max_lens
@@ -146,6 +136,8 @@ impl<T: Item> Menu<T> {
.map(|len| Constraint::Length(len as u16))
.collect();
+ let height = self.matches.len().min(10).min(viewport.1 as usize);
+
self.size = (width as u16, height as u16);
// adjust scroll offsets if size changed
@@ -172,15 +164,7 @@ impl<T: Item> Menu<T> {
self.cursor.and_then(|cursor| {
self.matches
.get(cursor)
- .map(|(index, _score)| &self.options[*index as usize])
- })
- }
-
- pub fn selection_mut(&mut self) -> Option<&mut T> {
- self.cursor.and_then(|cursor| {
- self.matches
- .get(cursor)
- .map(|(index, _score)| &mut self.options[*index as usize])
+ .map(|(index, _score)| &self.options[*index])
})
}
@@ -193,71 +177,73 @@ impl<T: Item> Menu<T> {
}
}
-impl<T: Item + PartialEq> Menu<T> {
- pub fn replace_option(&mut self, old_option: &impl PartialEq<T>, new_option: T) {
- for option in &mut self.options {
- if old_option == option {
- *option = new_option;
- break;
- }
- }
- }
-}
-
use super::PromptEvent as MenuEvent;
impl<T: Item + 'static> Component for Menu<T> {
- fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
+ fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
let event = match event {
- Event::Key(event) => *event,
- _ => return EventResult::Ignored(None),
+ Event::Key(event) => event,
+ _ => return EventResult::Ignored,
};
- let close_fn: Option<Callback> = Some(Box::new(|compositor: &mut Compositor, _| {
+ let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
// remove the layer
compositor.pop();
- }));
-
- // Ignore tab key when supertab is turned on in order not to interfere
- // with it. (Is there a better way to do this?)
- if (event == key!(Tab) || event == shift!(Tab))
- && cx.editor.config().auto_completion
- && matches!(
- cx.editor.config().smart_tab,
- Some(SmartTabConfig {
- enable: true,
- supersede_menu: true,
- })
- )
- {
- return EventResult::Ignored(None);
- }
+ })));
match event {
// esc or ctrl-c aborts the completion and closes the menu
- key!(Esc) | ctrl!('c') => {
+ KeyEvent {
+ code: KeyCode::Esc, ..
+ }
+ | KeyEvent {
+ code: KeyCode::Char('c'),
+ modifiers: KeyModifiers::CONTROL,
+ } => {
(self.callback_fn)(cx.editor, self.selection(), MenuEvent::Abort);
- return EventResult::Consumed(close_fn);
+ return close_fn;
}
// arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc)
- shift!(Tab) | key!(Up) | ctrl!('p') => {
+ KeyEvent {
+ code: KeyCode::BackTab,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Up, ..
+ }
+ | KeyEvent {
+ code: KeyCode::Char('p'),
+ modifiers: KeyModifiers::CONTROL,
+ } => {
self.move_up();
(self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update);
return EventResult::Consumed(None);
}
- key!(Tab) | key!(Down) | ctrl!('n') => {
- // arrow down/ctrl-n/tab advances completion choice (including updating the doc)
+ // arrow down/ctrl-n/tab advances completion choice (including updating the doc)
+ KeyEvent {
+ code: KeyCode::Tab,
+ modifiers: KeyModifiers::NONE,
+ }
+ | KeyEvent {
+ code: KeyCode::Down,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Char('n'),
+ modifiers: KeyModifiers::CONTROL,
+ } => {
self.move_down();
(self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update);
return EventResult::Consumed(None);
}
- key!(Enter) => {
+ KeyEvent {
+ code: KeyCode::Enter,
+ ..
+ } => {
if let Some(selection) = self.selection() {
(self.callback_fn)(cx.editor, Some(selection), MenuEvent::Validate);
- return EventResult::Consumed(close_fn);
- } else {
- return EventResult::Ignored(close_fn);
}
+ return close_fn;
}
// KeyEvent {
// code: KeyCode::Char(c),
@@ -277,7 +263,7 @@ impl<T: Item + 'static> Component for Menu<T> {
// for some events, we want to process them but send ignore, specifically all input except
// tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll.
// EventResult::Consumed(None)
- EventResult::Ignored(None)
+ EventResult::Ignored
}
fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
@@ -295,8 +281,6 @@ impl<T: Item + 'static> Component for Menu<T> {
.unwrap_or_else(|| theme.get("ui.text"));
let selected = theme.get("ui.menu.selected");
- surface.clear_with(area, style);
-
let scroll = self.scroll;
let options: Vec<_> = self
@@ -304,7 +288,7 @@ impl<T: Item + 'static> Component for Menu<T> {
.iter()
.map(|(index, _score)| {
// (index, self.options.get(*index).unwrap()) // get_unchecked
- &self.options[*index as usize] // get_unchecked
+ &self.options[*index] // get_unchecked
})
.collect();
@@ -312,9 +296,16 @@ impl<T: Item + 'static> Component for Menu<T> {
let win_height = area.height as usize;
- let rows = options
- .iter()
- .map(|option| option.format(&self.editor_data));
+ const fn div_ceil(a: usize, b: usize) -> usize {
+ (a + b - 1) / a
+ }
+
+ let scroll_height = std::cmp::min(div_ceil(win_height.pow(2), len), win_height as usize);
+
+ let scroll_line = (win_height - scroll_height) * scroll
+ / std::cmp::max(1, len.saturating_sub(win_height));
+
+ let rows = options.iter().map(|option| option.row());
let table = Table::new(rows)
.style(style)
.highlight_style(selected)
@@ -324,53 +315,22 @@ impl<T: Item + 'static> Component for Menu<T> {
use tui::widgets::TableState;
table.render_table(
- area.clip_left(Self::LEFT_PADDING as u16).clip_right(1),
+ area,
surface,
&mut TableState {
offset: scroll,
selected: self.cursor,
},
- false,
);
- let render_borders = cx.editor.menu_border();
-
- if !render_borders {
- if let Some(cursor) = self.cursor {
- let offset_from_top = cursor - scroll;
- let left = &mut surface[(area.left(), area.y + offset_from_top as u16)];
- left.set_style(selected);
- let right = &mut surface[(
- area.right().saturating_sub(1),
- area.y + offset_from_top as u16,
- )];
- right.set_style(selected);
- }
- }
-
- let fits = len <= win_height;
-
- let scroll_style = theme.get("ui.menu.scroll");
- if !fits {
- let scroll_height = win_height.pow(2).div_ceil(len).min(win_height);
- let scroll_line = (win_height - scroll_height) * scroll
- / std::cmp::max(1, len.saturating_sub(win_height));
+ for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() {
+ let is_marked = i >= scroll_line && i < scroll_line + scroll_height;
- let mut cell;
- for i in 0..win_height {
- cell = &mut surface[(area.right() - 1, area.top() + i as u16)];
-
- let half_block = if render_borders { "▌" } else { "▐" };
-
- if scroll_line <= i && i < scroll_line + scroll_height {
- // Draw scroll thumb
- cell.set_symbol(half_block);
- cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset));
- } else if !render_borders {
- // Draw scroll track
- cell.set_symbol(half_block);
- cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset));
- }
+ if is_marked {
+ let cell = surface.get_mut(area.x + area.width - 2, area.y + i as u16);
+ cell.set_symbol("▐ ");
+ // cell.set_style(selected);
+ // cell.set_style(if is_marked { selected } else { style });
}
}
}