on master help sign in
ui: Rework command mode, implement file path completion.
commit857bce0e301e81e3a90fd2f8a9683327bfc395d4 authorBlaž Hrastnik <blaz@mxxn.io> dateMon 1 Mar 2021, 18:02 (6 years ago) parenta16c6e2
3 files changed+127−18
helix-term/src/commands.rs+29−14 helix-term/src/ui/mod.rs+76 helix-term/src/ui/prompt.rs+22−4
helix-term/src/commands.rs+29−14
@@ -519,22 +519,37 @@ pub fn append_mode(cx: &mut Context) {
519519 doc.set_selection(selection);
520520}
521521
522+const COMMAND_LIST: &[&str] = &["write", "open", "quit"];
523+
522524// TODO: I, A, o and O can share a lot of the primitives.
523525pub fn command_mode(cx: &mut Context) {
524526 let executor = cx.executor;
525527 let prompt = Prompt::new(
526528 ":".to_owned(),
527− |_input: &str| {
528− let command_list = vec![
529− "q".to_string(),
530− "o".to_string(),
531− "w".to_string(),
532− // String::from("q"),
533− ];
534− command_list
535− .into_iter()
536− .filter(|command| command.contains(_input))
537− .collect()
529+ |input: &str| {
530+ // we use .this over split_ascii_whitespace() because we care about empty segments
531+ let parts = input.split(' ').collect::<Vec<&str>>();
532+
533+ // simple heuristic: if there's no space, complete command.
534+ // if there's a space, file completion kicks in. We should specialize by command later.
535+ if parts.len() <= 1 {
536+ COMMAND_LIST
537+ .iter()
538+ .filter(|command| command.contains(input))
539+ .map(|command| std::borrow::Cow::Borrowed(*command))
540+ .collect()
541+ } else {
542+ let part = parts.last().unwrap();
543+ ui::completers::filename(part)
544+
545+ // TODO
546+ // completion needs to be more advanced: need to return starting index for replace
547+ // for example, "src/" completion application.rs needs to insert after /, but "hx"
548+ // completion helix-core needs to replace the text.
549+ //
550+ // additionally, completion items could have a info section that would get
551+ // displayed in a popup above the prompt when items are tabbed over
552+ }
538553 }, // completion
539554 move |editor: &mut Editor, input: &str, event: PromptEvent| {
540555 if event != PromptEvent::Validate {
@@ -544,14 +559,14 @@ pub fn command_mode(cx: &mut Context) {
544559 let parts = input.split_ascii_whitespace().collect::<Vec<&str>>();
545560
546561 match *parts.as_slice() {
547− ["q"] => {
562+ ["q"] | ["quit"] => {
548563 editor.tree.remove(editor.view().id);
549564 // editor.should_close = true,
550565 }
551− ["o", path] => {
566+ ["o", path] | ["open", path] => {
552567 editor.open(path.into(), executor);
553568 }
554− ["w"] => {
569+ ["w"] | ["write"] => {
555570 // TODO: non-blocking via save() command
556571 smol::block_on(editor.view_mut().doc.save());
557572 }
helix-term/src/ui/mod.rs+76
@@ -124,3 +124,79 @@ pub fn buffer_picker(views: &[View], current: usize) -> Picker<(Option<PathBuf>,
124124 // },
125125 // )
126126}
127+
128+pub mod completers {
129+ use std::borrow::Cow;
130+ // TODO: we could return an iter/lazy thing so it can fetch as many as it needs.
131+ pub fn filename(input: &str) -> Vec<Cow<'static, str>> {
132+ // Rust's filename handling is really annoying.
133+
134+ use ignore::WalkBuilder;
135+ use std::path::{Path, PathBuf};
136+
137+ let path = Path::new(input);
138+
139+ let (dir, file_name) = if input.ends_with('/') {
140+ (path.into(), None)
141+ } else {
142+ let file_name = path
143+ .file_name()
144+ .map(|file| file.to_str().unwrap().to_owned());
145+
146+ let path = match path.parent() {
147+ Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(),
148+ // Path::new("h")'s parent is Some("")...
149+ _ => std::env::current_dir().expect("couldn't determine current directory"),
150+ };
151+
152+ (path, file_name)
153+ };
154+
155+ let mut files: Vec<_> = WalkBuilder::new(dir.clone())
156+ .max_depth(Some(1))
157+ .build()
158+ .filter_map(|file| {
159+ file.ok().map(|entry| {
160+ let is_dir = entry
161+ .file_type()
162+ .map(|entry| entry.is_dir())
163+ .unwrap_or(false);
164+
165+ let mut path = entry.path().strip_prefix(&dir).unwrap().to_path_buf();
166+
167+ if is_dir {
168+ path.push("");
169+ }
170+ Cow::from(path.to_str().unwrap().to_string())
171+ })
172+ }) // TODO: unwrap or skip
173+ .filter(|path| !path.is_empty()) // TODO
174+ .collect();
175+
176+ // if empty, return a list of dirs and files in current dir
177+ if let Some(file_name) = file_name {
178+ use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
179+ use fuzzy_matcher::FuzzyMatcher;
180+ use std::cmp::Reverse;
181+
182+ let matcher = Matcher::default();
183+
184+ // inefficient, but we need to calculate the scores, filter out None, then sort.
185+ let mut matches: Vec<_> = files
186+ .into_iter()
187+ .filter_map(|file| {
188+ matcher
189+ .fuzzy_match(&file, &file_name)
190+ .map(|score| (file, score))
191+ })
192+ .collect();
193+
194+ matches.sort_unstable_by_key(|(_file, score)| Reverse(*score));
195+ files = matches.into_iter().map(|(file, _)| file.into()).collect();
196+
197+ // TODO: complete to longest common match
198+ }
199+
200+ files
201+ }
202+}
helix-term/src/ui/prompt.rs+22−4
@@ -3,15 +3,16 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
33use helix_core::Position;
44use helix_view::Editor;
55use helix_view::Theme;
6+use std::borrow::Cow;
67use std::string::String;
78
89pub struct Prompt {
910 pub prompt: String,
1011 pub line: String,
1112 pub cursor: usize,
12− pub completion: Vec<String>,
13+ pub completion: Vec<Cow<'static, str>>,
1314 pub completion_selection_index: Option<usize>,
14− completion_fn: Box<dyn FnMut(&str) -> Vec<String>>,
15+ completion_fn: Box<dyn FnMut(&str) -> Vec<Cow<'static, str>>>,
1516 callback_fn: Box<dyn FnMut(&mut Editor, &str, PromptEvent)>,
1617}
1718
@@ -28,7 +29,7 @@ pub enum PromptEvent {
2829impl Prompt {
2930 pub fn new(
3031 prompt: String,
31− mut completion_fn: impl FnMut(&str) -> Vec<String> + 'static,
32+ mut completion_fn: impl FnMut(&str) -> Vec<Cow<'static, str>> + 'static,
3233 callback_fn: impl FnMut(&mut Editor, &str, PromptEvent) + 'static,
3334 ) -> Prompt {
3435 Prompt {
@@ -83,7 +84,19 @@ impl Prompt {
8384 let index =
8485 self.completion_selection_index.map(|i| i + 1).unwrap_or(0) % self.completion.len();
8586 self.completion_selection_index = Some(index);
86− self.line = self.completion[index].clone();
87+
88+ let item = &self.completion[index];
89+
90+ // replace the last arg
91+ if let Some(pos) = self.line.rfind(' ') {
92+ self.line.replace_range(pos + 1.., item);
93+ } else {
94+ // need toowned_clone_into nightly feature to reuse allocation
95+ self.line = item.to_string();
96+ }
97+
98+ self.move_end();
99+ // TODO: recalculate completion when completion item is accepted, (Enter)
87100 }
88101 pub fn exit_selection(&mut self) {
89102 self.completion_selection_index = None;
@@ -175,9 +188,14 @@ impl Component for Prompt {
175188 )));
176189
177190 match event {
191+ // char or shift char
178192 KeyEvent {
179193 code: KeyCode::Char(c),
180194 modifiers: KeyModifiers::NONE,
195+ }
196+ | KeyEvent {
197+ code: KeyCode::Char(c),
198+ modifiers: KeyModifiers::SHIFT,
181199 } => {
182200 self.insert_char(c);
183201 (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update);