Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-view/src/ui/menu.rs')
-rw-r--r--helix-view/src/ui/menu.rs333
1 files changed, 333 insertions, 0 deletions
diff --git a/helix-view/src/ui/menu.rs b/helix-view/src/ui/menu.rs
new file mode 100644
index 00000000..d85eb100
--- /dev/null
+++ b/helix-view/src/ui/menu.rs
@@ -0,0 +1,333 @@
+use crate::compositor::{
+ self, Callback, Component, Compositor, Context, Event, EventResult, RenderContext,
+};
+use crate::{ctrl, key, shift};
+
+use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
+use fuzzy_matcher::FuzzyMatcher;
+
+use crate::{graphics::Rect, Editor};
+
+use tui::layout::Constraint;
+pub use tui::widgets::{Cell, Row};
+
+pub trait Item {
+ fn label(&self) -> &str;
+
+ fn sort_text(&self) -> &str {
+ self.label()
+ }
+ fn filter_text(&self) -> &str {
+ self.label()
+ }
+
+ fn row(&self) -> Row {
+ Row::new(vec![Cell::from(self.label())])
+ }
+}
+
+pub struct Menu<T: Item> {
+ options: Vec<T>,
+
+ cursor: Option<usize>,
+
+ matcher: Box<Matcher>,
+ /// (index, score)
+ matches: Vec<(usize, i64)>,
+
+ widths: Vec<Constraint>,
+
+ callback_fn: Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>,
+
+ scroll: usize,
+ size: (u16, u16),
+ viewport: (u16, u16),
+ recalculate: bool,
+}
+
+impl<T: Item> Menu<T> {
+ // TODO: it's like a slimmed down picker, share code? (picker = menu + prompt with different
+ // rendering)
+ pub fn new(
+ options: Vec<T>,
+ callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
+ ) -> Self {
+ let mut menu = Self {
+ options,
+ matcher: Box::new(Matcher::default()),
+ matches: Vec::new(),
+ cursor: None,
+ widths: Vec::new(),
+ callback_fn: Box::new(callback_fn),
+ scroll: 0,
+ size: (0, 0),
+ viewport: (0, 0),
+ recalculate: true,
+ };
+
+ // TODO: scoring on empty input should just use a fastpath
+ menu.score("");
+
+ menu
+ }
+
+ 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 clear(&mut self) {
+ self.matches.clear();
+
+ // reset cursor position
+ self.cursor = None;
+ self.scroll = 0;
+ }
+
+ 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;
+ self.cursor = Some(pos);
+ self.adjust_scroll();
+ }
+
+ pub fn move_down(&mut self) {
+ let len = self.matches.len();
+ let pos = self.cursor.map_or(0, |i| i + 1) % len;
+ self.cursor = Some(pos);
+ self.adjust_scroll();
+ }
+
+ fn recalculate_size(&mut self, viewport: (u16, u16)) {
+ let n = self
+ .options
+ .first()
+ .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.row();
+ // maintain max for each column
+ for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) {
+ let width = cell.content.width();
+ if width > *acc {
+ *acc = width;
+ }
+ }
+
+ 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
+ }
+
+ let width = len.min(viewport.0 as usize);
+
+ self.widths = max_lens
+ .into_iter()
+ .map(|len| Constraint::Length(len as u16))
+ .collect();
+
+ self.size = (width as u16, height as u16);
+
+ // adjust scroll offsets if size changed
+ self.adjust_scroll();
+ self.recalculate = false;
+ }
+
+ fn adjust_scroll(&mut self) {
+ let win_height = self.size.1 as usize;
+ if let Some(cursor) = self.cursor {
+ let mut scroll = self.scroll;
+ if cursor > (win_height + scroll).saturating_sub(1) {
+ // scroll down
+ scroll += cursor - (win_height + scroll).saturating_sub(1)
+ } else if cursor < scroll {
+ // scroll up
+ scroll = cursor
+ }
+ self.scroll = scroll;
+ }
+ }
+
+ pub fn selection(&self) -> Option<&T> {
+ self.cursor.and_then(|cursor| {
+ self.matches
+ .get(cursor)
+ .map(|(index, _score)| &self.options[*index])
+ })
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.matches.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.matches.len()
+ }
+}
+
+use super::PromptEvent as MenuEvent;
+
+impl<T: Item + 'static> Component for Menu<T> {
+ fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
+ let event = match event {
+ Event::Key(event) => event,
+ _ => return EventResult::Ignored(None),
+ };
+
+ let close_fn: Option<Callback> = Some(Box::new(|compositor: &mut Compositor, _| {
+ // remove the layer
+ compositor.pop();
+ }));
+
+ match event {
+ // 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') | ctrl!('k') => {
+ self.move_up();
+ (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update);
+ return EventResult::Consumed(None);
+ }
+ 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);
+ return EventResult::Consumed(None);
+ }
+ key!(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);
+ }
+ }
+ // KeyEvent {
+ // code: KeyCode::Char(c),
+ // modifiers: KeyModifiers::NONE,
+ // } => {
+ // self.insert_char(c);
+ // (self.callback_fn)(cx.editor, &self.line, MenuEvent::Update);
+ // }
+
+ // / -> edit_filter?
+ //
+ // enter confirms the match and closes the menu
+ // typing filters the menu
+ // if we run out of options the menu closes itself
+ _ => (),
+ }
+ // 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)
+ }
+
+ fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
+ if viewport != self.viewport || self.recalculate {
+ self.recalculate_size(viewport);
+ }
+
+ Some(self.size)
+ }
+}
+
+#[cfg(feature = "term")]
+impl<T: Item + 'static> compositor::term::Render for Menu<T> {
+ fn render(&mut self, area: Rect, cx: &mut RenderContext<'_>) {
+ use tui::widgets::Table;
+
+ let theme = &cx.editor.theme;
+ let style = theme
+ .try_get("ui.menu")
+ .unwrap_or_else(|| theme.get("ui.text"));
+ let selected = theme.get("ui.menu.selected");
+
+ let scroll = self.scroll;
+
+ let options: Vec<_> = self
+ .matches
+ .iter()
+ .map(|(index, _score)| {
+ // (index, self.options.get(*index).unwrap()) // get_unchecked
+ &self.options[*index] // get_unchecked
+ })
+ .collect();
+
+ let len = options.len();
+
+ let win_height = area.height as usize;
+
+ 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)
+ .column_spacing(1)
+ .widths(&self.widths);
+
+ use tui::widgets::TableState;
+
+ table.render_table(
+ area,
+ cx.surface,
+ &mut TableState {
+ offset: scroll,
+ selected: self.cursor,
+ },
+ );
+
+ let fits = len <= win_height;
+
+ 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 = &mut cx.surface[(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 });
+ }
+ }
+ }
+}