diff options
Diffstat (limited to 'helix-term/src/ui')
-rw-r--r-- | helix-term/src/ui/editor.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/helix.log | 0 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 36 | ||||
-rw-r--r-- | helix-term/src/ui/picker.rs | 258 | ||||
-rw-r--r-- | helix-term/src/ui/prompt.rs | 61 |
5 files changed, 337 insertions, 20 deletions
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index ceb5a442..996e182f 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -226,7 +226,7 @@ impl EditorView { ); surface.set_string(1, viewport.y, mode, text_color); - if let Some(path) = view.doc.path() { + if let Some(path) = view.doc.relative_path() { surface.set_string(6, viewport.y, path.to_string_lossy(), text_color); } diff --git a/helix-term/src/ui/helix.log b/helix-term/src/ui/helix.log new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/helix-term/src/ui/helix.log diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index bc79e09c..b778f531 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,8 +1,10 @@ mod editor; +mod picker; mod prompt; pub use editor::EditorView; -pub use prompt::Prompt; +pub use picker::Picker; +pub use prompt::{Prompt, PromptEvent}; pub use tui::layout::Rect; pub use tui::style::{Color, Modifier, Style}; @@ -12,3 +14,35 @@ pub use tui::style::{Color, Modifier, Style}; pub fn text_color() -> Style { Style::default().fg(Color::Rgb(219, 191, 239)) // lilac } + +use std::path::PathBuf; +pub fn file_picker(root: &str) -> Picker<PathBuf> { + use ignore::Walk; + // TODO: determine root based on git root + let files = Walk::new(root).filter_map(|entry| match entry { + Ok(entry) => { + // filter dirs, but we might need special handling for symlinks! + if !entry.file_type().unwrap().is_dir() { + Some(entry.into_path()) + } else { + None + } + } + Err(_err) => None, + }); + + const MAX: usize = 1024; + + use helix_view::Editor; + Picker::new( + files.take(MAX).collect(), + |path: &PathBuf| { + // format_fn + path.strip_prefix("./").unwrap().to_str().unwrap() // TODO: render paths without ./ + }, + |editor: &mut Editor, path: &PathBuf| { + let size = editor.view().unwrap().size; + editor.open(path.into(), size); + }, + ) +} diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs new file mode 100644 index 00000000..0a12cff9 --- /dev/null +++ b/helix-term/src/ui/picker.rs @@ -0,0 +1,258 @@ +use crate::compositor::{Component, Compositor, Context, EventResult}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use tui::buffer::Buffer as Surface; +use tui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders}, +}; + +use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; +use fuzzy_matcher::FuzzyMatcher; + +use crate::ui::{Prompt, PromptEvent}; +use helix_core::Position; +use helix_view::Editor; + +pub struct Picker<T> { + options: Vec<T>, + // filter: String, + matcher: Box<Matcher>, + /// (index, score) + matches: Vec<(usize, i64)>, + + cursor: usize, + // pattern: String, + prompt: Prompt, + + format_fn: Box<dyn Fn(&T) -> &str>, + callback_fn: Box<dyn Fn(&mut Editor, &T)>, +} + +impl<T> Picker<T> { + pub fn new( + options: Vec<T>, + format_fn: impl Fn(&T) -> &str + 'static, + callback_fn: impl Fn(&mut Editor, &T) + 'static, + ) -> Self { + let prompt = Prompt::new( + "".to_string(), + |pattern: &str| Vec::new(), + |editor: &mut Editor, pattern: &str, event: PromptEvent| { + // + }, + ); + + let mut picker = Self { + options, + matcher: Box::new(Matcher::default()), + matches: Vec::new(), + cursor: 0, + prompt, + format_fn: Box::new(format_fn), + callback_fn: Box::new(callback_fn), + }; + + // TODO: scoring on empty input should just use a fastpath + picker.score(); + + picker + } + + pub fn score(&mut self) { + // need to borrow via pattern match otherwise it complains about simultaneous borrow + let Self { + ref mut options, + ref mut matcher, + ref mut matches, + ref format_fn, + .. + } = *self; + + let pattern = &self.prompt.line; + + // reuse the matches allocation + matches.clear(); + matches.extend( + self.options + .iter() + .enumerate() + .filter_map(|(index, option)| { + // TODO: maybe using format_fn isn't the best idea here + let text = (format_fn)(option); + // TODO: using fuzzy_indices could give us the char idx for match highlighting + matcher + .fuzzy_match(text, pattern) + .map(|score| (index, score)) + }), + ); + matches.sort_unstable_by_key(|(_, score)| -score); + + // reset cursor position + self.cursor = 0; + } + + pub fn move_up(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn move_down(&mut self) { + // TODO: len - 1 + if self.cursor < self.options.len() { + self.cursor += 1; + } + } + + pub fn selection(&self) -> Option<&T> { + self.matches + .get(self.cursor) + .map(|(index, _score)| &self.options[*index]) + } +} + +// process: +// - read all the files into a list, maxed out at a large value +// - on input change: +// - score all the names in relation to input + +impl<T> Component for Picker<T> { + fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { + let key_event = match event { + Event::Key(event) => event, + Event::Resize(..) => return EventResult::Consumed(None), + _ => return EventResult::Ignored, + }; + + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + // remove the layer + compositor.pop(); + }))); + + match key_event { + // KeyEvent { + // code: KeyCode::Char(c), + // modifiers: KeyModifiers::NONE, + // } => { + // self.insert_char(c); + // (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update); + // } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + } => self.move_down(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + return close_fn; + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + if let Some(option) = self.selection() { + (self.callback_fn)(&mut cx.editor, option); + } + return close_fn; + } + _ => { + match self.prompt.handle_event(event, cx) { + EventResult::Consumed(_) => { + // TODO: recalculate only if pattern changed + self.score(); + } + _ => (), + } + } + } + + EventResult::Consumed(None) + } + + fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + let padding_vertical = area.height * 20 / 100; + let padding_horizontal = area.width * 20 / 100; + + let area = Rect::new( + area.x + padding_horizontal, + area.y + padding_vertical, + area.width - padding_horizontal * 2, + area.height - padding_vertical * 2, + ); + + // -- Render the frame: + + // clear area + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + surface.get_mut(x, y).reset() + } + } + + use tui::widgets::Widget; + // don't like this but the lifetime sucks + let block = Block::default().borders(Borders::ALL); + + // calculate the inner area inside the box + let inner = block.inner(area); + + block.render(area, surface); + // TODO: abstract into a clear(area) fn + // surface.set_style(inner, Style::default().bg(Color::Rgb(150, 50, 0))); + + // -- Render the input bar: + + let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1); + self.prompt.render(area, surface, cx); + + // -- Separator + use tui::widgets::BorderType; + let style = Style::default().fg(Color::Rgb(90, 89, 119)); + let symbols = BorderType::line_symbols(BorderType::Plain); + for x in inner.left()..inner.right() { + surface + .get_mut(x, inner.y + 1) + .set_symbol(symbols.horizontal) + .set_style(style); + } + + // -- Render the contents: + + let style = Style::default().fg(Color::Rgb(164, 160, 232)); // lavender + let selected = Style::default().fg(Color::Rgb(255, 255, 255)); + + let rows = inner.height - 2; // -1 for search bar + + let files = self.matches.iter().map(|(index, _score)| { + (index, self.options.get(*index).unwrap()) // get_unchecked + }); + + for (i, (_index, option)) in files.take(rows as usize).enumerate() { + if i == self.cursor { + surface.set_string(inner.x + 1, inner.y + 2 + i as u16, ">", selected); + } + + surface.set_stringn( + inner.x + 3, + inner.y + 2 + i as u16, + (self.format_fn)(option), + inner.width as usize - 1, + if i == self.cursor { selected } else { style }, + ); + } + } + + fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> { + self.prompt.cursor_position(area, ctx) + } +} diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index ce00a129..58efd560 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -10,24 +10,32 @@ pub struct Prompt { pub line: String, pub cursor: usize, pub completion: Vec<String>, - pub should_close: bool, pub completion_selection_index: Option<usize>, completion_fn: Box<dyn FnMut(&str) -> Vec<String>>, - callback_fn: Box<dyn FnMut(&mut Editor, &str)>, + callback_fn: Box<dyn FnMut(&mut Editor, &str, PromptEvent)>, +} + +#[derive(PartialEq)] +pub enum PromptEvent { + /// The prompt input has been updated. + Update, + /// Validate and finalize the change. + Validate, + /// Abort the change, reverting to the initial state. + Abort, } impl Prompt { pub fn new( prompt: String, mut completion_fn: impl FnMut(&str) -> Vec<String> + 'static, - callback_fn: impl FnMut(&mut Editor, &str) + 'static, + callback_fn: impl FnMut(&mut Editor, &str, PromptEvent) + 'static, ) -> Prompt { Prompt { prompt, line: String::new(), cursor: 0, completion: completion_fn(""), - should_close: false, completion_selection_index: None, completion_fn: Box::new(completion_fn), callback_fn: Box::new(callback_fn), @@ -42,9 +50,7 @@ impl Prompt { } pub fn move_char_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - } + self.cursor = self.cursor.saturating_sub(1) } pub fn move_char_right(&mut self) { @@ -141,9 +147,15 @@ impl Prompt { } } } + let line = area.height - 1; // render buffer text - surface.set_string(1, area.height - 1, &self.prompt, text_color); - surface.set_string(2, area.height - 1, &self.line, text_color); + surface.set_string(area.x, area.y + line, &self.prompt, text_color); + surface.set_string( + area.x + self.prompt.len() as u16, + area.y + line, + &self.line, + text_color, + ); } } @@ -151,21 +163,28 @@ impl Component for Prompt { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { let event = match event { Event::Key(event) => event, + Event::Resize(..) => return EventResult::Consumed(None), _ => return EventResult::Ignored, }; + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + // remove the layer + compositor.pop(); + }))); + match event { KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, - } => self.insert_char(c), + } => { + self.insert_char(c); + (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update); + } KeyEvent { code: KeyCode::Esc, .. } => { - return EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { - // remove the layer - compositor.pop(); - }))); + (self.callback_fn)(cx.editor, &self.line, PromptEvent::Abort); + return close_fn; } KeyEvent { code: KeyCode::Right, @@ -186,11 +205,17 @@ impl Component for Prompt { KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::NONE, - } => self.delete_char_backwards(), + } => { + self.delete_char_backwards(); + (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update); + } KeyEvent { code: KeyCode::Enter, .. - } => (self.callback_fn)(cx.editor, &self.line), + } => { + (self.callback_fn)(cx.editor, &self.line, PromptEvent::Validate); + return close_fn; + } KeyEvent { code: KeyCode::Tab, .. } => self.change_completion_selection(), @@ -210,8 +235,8 @@ impl Component for Prompt { fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> { Some(Position::new( - area.height as usize - 1, - area.x as usize + 2 + self.cursor, + area.height as usize, + area.x as usize + self.prompt.len() + self.cursor, )) } } |