diff options
author | Blaž Hrastnik | 2020-12-18 10:24:50 +0000 |
---|---|---|
committer | GitHub | 2020-12-18 10:24:50 +0000 |
commit | 3f0dbfcac878131167953b6f57c923a5bc889e80 (patch) | |
tree | 41b876f9bb067e5f199fe53ecb78eeb57d09719b /helix-term | |
parent | b12a6dc8303bbc1b4b08a9abb4668741d154adbd (diff) | |
parent | 25aa45e76c9bec62f36a59768298e1f2ea2678bf (diff) |
Merge pull request #7 from helix-editor/interactive-split-select
File picker/interactive split prompt
Diffstat (limited to 'helix-term')
-rw-r--r-- | helix-term/Cargo.toml | 4 | ||||
-rw-r--r-- | helix-term/src/commands.rs | 81 | ||||
-rw-r--r-- | helix-term/src/compositor.rs | 2 | ||||
-rw-r--r-- | helix-term/src/keymap.rs | 3 | ||||
-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 |
9 files changed, 422 insertions, 25 deletions
diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index b8eea7c2..645ed155 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -32,3 +32,7 @@ futures-util = "0.3" fern = "0.6" chrono = "0.4" log = "0.4" + +# File picker +fuzzy-matcher = "0.3" +ignore = "0.4" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index b345d2e8..5f8f63f1 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -10,7 +10,7 @@ use helix_core::{ use once_cell::sync::Lazy; use crate::compositor::Compositor; -use crate::ui::Prompt; +use crate::ui::{self, Prompt, PromptEvent}; use helix_view::{ document::Mode, @@ -248,6 +248,60 @@ pub fn extend_line_down(cx: &mut Context) { cx.view.doc.set_selection(selection); } +pub fn split_selection(cx: &mut Context) { + // TODO: this needs to store initial selection state, revert on esc, confirm on enter + // needs to also call the callback function per input change, not just final time. + // could cheat and put it into completion_fn + // + // kakoune does it like this: + // # save state to register + // { + // # restore state from register + // # if event == abort, return early + // # add to history if enabled + // # update state + // } + + let snapshot = cx.view.doc.state.clone(); + + let prompt = Prompt::new( + "split:".to_string(), + |input: &str| Vec::new(), // this is fine because Vec::new() doesn't allocate + move |editor: &mut Editor, input: &str, event: PromptEvent| { + match event { + PromptEvent::Abort => { + // revert state + let view = editor.view_mut().unwrap(); + view.doc.state = snapshot.clone(); + } + PromptEvent::Validate => { + // + } + PromptEvent::Update => { + match Regex::new(input) { + Ok(regex) => { + let view = editor.view_mut().unwrap(); + + // revert state to what it was before the last update + view.doc.state = snapshot.clone(); + + let text = &view.doc.text().slice(..); + let selection = + selection::split_on_matches(text, view.doc.selection(), ®ex); + view.doc.set_selection(selection); + } + Err(_) => (), // TODO: mark command line as error + } + } + } + }, + ); + + cx.callback = Some(Box::new(move |compositor: &mut Compositor| { + compositor.push(Box::new(prompt)); + })); +} + pub fn split_selection_on_newline(cx: &mut Context) { let text = &cx.view.doc.text().slice(..); // only compile the regex once @@ -381,14 +435,33 @@ pub fn command_mode(cx: &mut Context) { .filter(|command| command.contains(_input)) .collect() }, // completion - |editor: &mut Editor, input: &str| match input { - "q" => editor.should_close = true, - _ => (), + |editor: &mut Editor, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let parts = input.split_ascii_whitespace().collect::<Vec<&str>>(); + + match parts.as_slice() { + &["q"] => editor.should_close = true, + &["o", path] => { + // TODO: make view()/view_mut() always contain a view. + let size = editor.view().unwrap().size; + editor.open(path.into(), size); + } + _ => (), + } }, ); compositor.push(Box::new(prompt)); })); } +pub fn file_picker(cx: &mut Context) { + cx.callback = Some(Box::new(|compositor: &mut Compositor| { + let picker = ui::file_picker("./"); + compositor.push(Box::new(picker)); + })); +} // calculate line numbers for each selection range fn selection_lines(state: &State) -> Vec<usize> { diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 2e65f02a..f0d94dbc 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -19,7 +19,7 @@ use smol::Executor; use tui::buffer::Buffer as Surface; use tui::layout::Rect; -pub type Callback = Box<dyn Fn(&mut Compositor)>; +pub type Callback = Box<dyn FnOnce(&mut Compositor)>; // --> EventResult should have a callback that takes a context with methods like .popup(), // .prompt() etc. That way we can abstract it from the renderer. diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index af46f7a4..a31676e4 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -157,6 +157,7 @@ pub fn default() -> Keymaps { vec![key!('d')] => commands::delete_selection, vec![key!('c')] => commands::change_selection, vec![key!('s')] => commands::split_selection_on_newline, + vec![shift!('S')] => commands::split_selection, vec![key!(';')] => commands::collapse_selection, // TODO should be alt(;) vec![key!('%')] => commands::flip_selections, @@ -182,6 +183,8 @@ pub fn default() -> Keymaps { }] => commands::page_down, vec![ctrl!('u')] => commands::half_page_up, vec![ctrl!('d')] => commands::half_page_down, + + vec![ctrl!('p')] => commands::file_picker, ), Mode::Insert => hashmap!( vec![Key { 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, )) } } |