From 857bce0e301e81e3a90fd2f8a9683327bfc395d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Hrastnik?= Date: Mon, 1 Mar 2021 18:02:31 +0900 Subject: [PATCH] ui: Rework command mode, implement file path completion. --- helix-term/src/commands.rs | 43 ++++++++++++++------- helix-term/src/ui/mod.rs | 76 +++++++++++++++++++++++++++++++++++++ helix-term/src/ui/prompt.rs | 26 +++++++++++-- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 55cbf0fed..4a329f289 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -519,22 +519,37 @@ pub fn append_mode(cx: &mut Context) { doc.set_selection(selection); } +const COMMAND_LIST: &[&str] = &["write", "open", "quit"]; + // TODO: I, A, o and O can share a lot of the primitives. pub fn command_mode(cx: &mut Context) { let executor = cx.executor; let prompt = Prompt::new( ":".to_owned(), - |_input: &str| { - let command_list = vec![ - "q".to_string(), - "o".to_string(), - "w".to_string(), - // String::from("q"), - ]; - command_list - .into_iter() - .filter(|command| command.contains(_input)) - .collect() + |input: &str| { + // we use .this over split_ascii_whitespace() because we care about empty segments + let parts = input.split(' ').collect::>(); + + // simple heuristic: if there's no space, complete command. + // if there's a space, file completion kicks in. We should specialize by command later. + if parts.len() <= 1 { + COMMAND_LIST + .iter() + .filter(|command| command.contains(input)) + .map(|command| std::borrow::Cow::Borrowed(*command)) + .collect() + } else { + let part = parts.last().unwrap(); + ui::completers::filename(part) + + // TODO + // completion needs to be more advanced: need to return starting index for replace + // for example, "src/" completion application.rs needs to insert after /, but "hx" + // completion helix-core needs to replace the text. + // + // additionally, completion items could have a info section that would get + // displayed in a popup above the prompt when items are tabbed over + } }, // completion move |editor: &mut Editor, input: &str, event: PromptEvent| { if event != PromptEvent::Validate { @@ -544,14 +559,14 @@ pub fn command_mode(cx: &mut Context) { let parts = input.split_ascii_whitespace().collect::>(); match *parts.as_slice() { - ["q"] => { + ["q"] | ["quit"] => { editor.tree.remove(editor.view().id); // editor.should_close = true, } - ["o", path] => { + ["o", path] | ["open", path] => { editor.open(path.into(), executor); } - ["w"] => { + ["w"] | ["write"] => { // TODO: non-blocking via save() command smol::block_on(editor.view_mut().doc.save()); } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index ea1d22abe..1526a210b 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -124,3 +124,79 @@ pub fn buffer_picker(views: &[View], current: usize) -> Picker<(Option, // }, // ) } + +pub mod completers { + use std::borrow::Cow; + // TODO: we could return an iter/lazy thing so it can fetch as many as it needs. + pub fn filename(input: &str) -> Vec> { + // Rust's filename handling is really annoying. + + use ignore::WalkBuilder; + use std::path::{Path, PathBuf}; + + let path = Path::new(input); + + let (dir, file_name) = if input.ends_with('/') { + (path.into(), None) + } else { + let file_name = path + .file_name() + .map(|file| file.to_str().unwrap().to_owned()); + + let path = match path.parent() { + Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(), + // Path::new("h")'s parent is Some("")... + _ => std::env::current_dir().expect("couldn't determine current directory"), + }; + + (path, file_name) + }; + + let mut files: Vec<_> = WalkBuilder::new(dir.clone()) + .max_depth(Some(1)) + .build() + .filter_map(|file| { + file.ok().map(|entry| { + let is_dir = entry + .file_type() + .map(|entry| entry.is_dir()) + .unwrap_or(false); + + let mut path = entry.path().strip_prefix(&dir).unwrap().to_path_buf(); + + if is_dir { + path.push(""); + } + Cow::from(path.to_str().unwrap().to_string()) + }) + }) // TODO: unwrap or skip + .filter(|path| !path.is_empty()) // TODO + .collect(); + + // if empty, return a list of dirs and files in current dir + if let Some(file_name) = file_name { + use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; + use fuzzy_matcher::FuzzyMatcher; + use std::cmp::Reverse; + + let matcher = Matcher::default(); + + // inefficient, but we need to calculate the scores, filter out None, then sort. + let mut matches: Vec<_> = files + .into_iter() + .filter_map(|file| { + matcher + .fuzzy_match(&file, &file_name) + .map(|score| (file, score)) + }) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| Reverse(*score)); + files = matches.into_iter().map(|(file, _)| file.into()).collect(); + + // TODO: complete to longest common match + } + + files + } +} diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 7228b38cf..700bc8a04 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -3,15 +3,16 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use helix_core::Position; use helix_view::Editor; use helix_view::Theme; +use std::borrow::Cow; use std::string::String; pub struct Prompt { pub prompt: String, pub line: String, pub cursor: usize, - pub completion: Vec, + pub completion: Vec>, pub completion_selection_index: Option, - completion_fn: Box Vec>, + completion_fn: Box Vec>>, callback_fn: Box, } @@ -28,7 +29,7 @@ pub enum PromptEvent { impl Prompt { pub fn new( prompt: String, - mut completion_fn: impl FnMut(&str) -> Vec + 'static, + mut completion_fn: impl FnMut(&str) -> Vec> + 'static, callback_fn: impl FnMut(&mut Editor, &str, PromptEvent) + 'static, ) -> Prompt { Prompt { @@ -83,7 +84,19 @@ impl Prompt { let index = self.completion_selection_index.map(|i| i + 1).unwrap_or(0) % self.completion.len(); self.completion_selection_index = Some(index); - self.line = self.completion[index].clone(); + + let item = &self.completion[index]; + + // replace the last arg + if let Some(pos) = self.line.rfind(' ') { + self.line.replace_range(pos + 1.., item); + } else { + // need toowned_clone_into nightly feature to reuse allocation + self.line = item.to_string(); + } + + self.move_end(); + // TODO: recalculate completion when completion item is accepted, (Enter) } pub fn exit_selection(&mut self) { self.completion_selection_index = None; @@ -175,9 +188,14 @@ impl Component for Prompt { ))); match event { + // char or shift char KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, + } + | KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::SHIFT, } => { self.insert_char(c); (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update);