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.rs | 208 |
1 files changed, 82 insertions, 126 deletions
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 41cf96ac..8133cba8 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,35 +1,48 @@ use crate::{ - compositor::{Callback, Component, Compositor, Context, Event, EventResult}, + compositor::{Callback, Component, Compositor, Context, EventResult}, ctrl, key, shift, }; -use tui::{buffer::Buffer as Surface, widgets::Table}; +use crossterm::event::Event; +use tui::{ + buffer::{Buffer as Surface, SurfaceExt}, + 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 label(&self) -> &str; - fn format(&self, data: &Self::Data) -> Row<'_>; -} + fn sort_text(&self) -> &str { + self.label() + } + fn filter_text(&self) -> &str { + self.label() + } -pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>; + fn row(&self) -> Row { + Row::new(vec![Cell::from(self.label())]) + } +} 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 +51,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 +68,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(); @@ -113,10 +128,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(); @@ -138,7 +153,6 @@ impl<T: Item> Menu<T> { len += 1; // +1: reserve some space for scrollbar } - len += Self::LEFT_PADDING; let width = len.min(viewport.0 as usize); self.widths = max_lens @@ -172,15 +186,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,23 +199,12 @@ 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, + Event::Key(event) => event, _ => return EventResult::Ignored(None), }; @@ -218,34 +213,19 @@ impl<T: Item + 'static> Component for Menu<T> { 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 { + match event.into() { // esc or ctrl-c aborts the completion and closes the menu key!(Esc) | ctrl!('c') => { (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Abort); return EventResult::Consumed(close_fn); } // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) - shift!(Tab) | key!(Up) | ctrl!('p') => { + shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); } - key!(Tab) | key!(Down) | ctrl!('n') => { + key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { // arrow down/ctrl-n/tab advances completion choice (including updating the doc) self.move_down(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); @@ -295,8 +275,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 +282,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 +290,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 +309,24 @@ 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)); - - 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)); - } + for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() { + let is_marked = i >= scroll_line && i < scroll_line + scroll_height; + + if !fits && 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 }); } } } |